diff --git a/Cargo.toml b/Cargo.toml index 3d82d553..cd1da9e6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ categories = ["network-programming", "web-programming"] exclude.workspace = true [workspace.package] -version = "0.30.10" +version = "0.31.1" readme = "README.md" edition = "2024" authors = ["root@ltpp.vip"] @@ -38,13 +38,13 @@ resolver = "3" crate-type = ["rlib"] [workspace.dependencies] -euv = { path = ".", version = "0.30.10" } -euv-ui = { path = "ui", version = "0.30.10" } -euv-cli = { path = "cli", version = "0.30.10" } -euv-core = { path = "core", version = "0.30.10" } -euv-engine = { path = "engine", version = "0.30.10" } -euv-macros = { path = "macros", version = "0.30.10" } -euv-example = { path = "example", version = "0.30.10" } +euv = { path = ".", version = "0.31.1" } +euv-ui = { path = "ui", version = "0.31.1" } +euv-cli = { path = "cli", version = "0.31.1" } +euv-core = { path = "core", version = "0.31.1" } +euv-engine = { path = "engine", version = "0.31.1" } +euv-macros = { path = "macros", version = "0.31.1" } +euv-example = { path = "example", version = "0.31.1" } log = "0.4.34" toml = "0.9.12" @@ -57,7 +57,7 @@ hyperlane = "21.12.0" serde_json = "1.0.151" color-output = "10.2.3" proc-macro2 = "1.0.107" -lombok-macros = "10.2.3" +lombok-macros = "10.2.7" wasm-bindgen = "0.2.129" alloc-no-stdlib = "3.0.0" hyperlane-cli = "21.12.0" diff --git a/cli/src/build/struct.rs b/cli/src/build/struct.rs index f407dc9c..4698d5a1 100644 --- a/cli/src/build/struct.rs +++ b/cli/src/build/struct.rs @@ -11,9 +11,17 @@ use super::*; pub struct Cli { /// The mode to run in: run (build + server), build (build only), or fmt (format) #[command(subcommand)] + /// + /// Public: `cli/src/main.rs` is the `--bin euv` crate root, a + /// different crate from the `euv_cli` library, so its + /// `cli.get_command()` call cannot see a `pub(crate)` accessor. The + /// field is public to match that getter rather than the getter + /// narrowed to `pub(crate)`, which would fail with E0624 at the bin + /// call site. `get_mut` / `set` stay crate-internal — clap's derive + /// is the only writer and nothing mutates the mode in place. #[get_mut(pub(crate))] #[set(pub(crate))] - pub(crate) command: Mode, + pub command: Mode, } /// euv-specific arguments combined with wasm-pack passthrough. @@ -84,14 +92,26 @@ pub struct ModeArgs { /// /// Formats euv macro invocations (`html!`, `class!`, `vars!`, `watch!`) /// in Rust source files. -#[derive(Clone, Data, Debug, New, Parser)] +#[derive(Clone, Debug, Getter, New, Parser)] pub struct FmtArgs { /// Path to the directory or file to format #[arg(short, long, default_value = ".")] + /// + /// Narrowed to `pub(crate)`: the only getter call sites are in + /// `cli/src/mode/fn.rs`, which is inside this crate. The integration + /// test constructs a `FmtArgs` through `FmtArgs::new` / + /// `FmtArgs::parse_from` but never reads the field, so no public + /// accessor is required. `Getter` does not generate mutators — no + /// caller anywhere mutates the path after parsing. + #[get(pub(crate))] pub(crate) path: PathBuf, /// Check if formatting is needed without modifying files #[arg(long, default_value_t = false)] - #[get(type(copy))] + /// + /// Narrowed for the same reason as [`FmtArgs::path`]: the flag is read + /// only by `cli/src/mode/fn.rs`, and clap's derive is what writes it, + /// so `Getter` is the right derive — it generates no mutators. + #[get(pub(crate), type(copy))] pub(crate) check: bool, } diff --git a/core/src/event/handler/struct.rs b/core/src/event/handler/struct.rs index a413871b..51eaf45b 100644 --- a/core/src/event/handler/struct.rs +++ b/core/src/event/handler/struct.rs @@ -10,10 +10,7 @@ use super::*; #[new(pub(crate))] pub struct NativeEventHandler { /// The name of the event (e.g., "click", "input"). - #[get(type(copy))] - #[get_mut(pub(crate))] - #[set(pub(crate))] - #[get(pub(crate))] + #[get(type(copy), pub(crate))] #[get_mut(pub(crate))] #[set(pub(crate))] pub(crate) event_name: &'static str, @@ -24,8 +21,5 @@ pub struct NativeEventHandler { #[get(pub(crate))] #[get_mut(pub(crate))] #[set(pub(crate))] - #[get(pub(crate))] - #[get_mut(pub(crate))] - #[set(pub(crate))] pub(crate) callback: SharedEventCallback, } diff --git a/core/src/reactive/cache/impl.rs b/core/src/reactive/cache/impl.rs index 0877ee17..c07fbfeb 100644 --- a/core/src/reactive/cache/impl.rs +++ b/core/src/reactive/cache/impl.rs @@ -29,7 +29,7 @@ where /// /// - `bool` - A boolean. pub fn is_full(&self) -> bool { - self.get_map().len() >= *self.get_capacity() + self.get_map().len() >= self.get_capacity() } /// Returns `true` if the cache contains a value for @@ -107,7 +107,7 @@ where /// - `Option<(K, V)>` - `Some(...)` on success, `None` otherwise. pub fn put(&mut self, key: K, value: V) -> Option<(K, V)> { // Capacity of 0 — silently drop. - if *self.get_capacity() == 0 { + if self.get_capacity() == 0 { return None; } // Updating an existing key. @@ -121,7 +121,7 @@ where return None; } // Inserting a new key. Evict if at capacity. - let evicted: Option<(K, V)> = if self.get_map().len() >= *self.get_capacity() { + let evicted: Option<(K, V)> = if self.get_map().len() >= self.get_capacity() { let victim_key: K = self.get_mut_order().pop_back()?; let victim_value: V = self.get_mut_map().remove(&victim_key)?; Some((victim_key, victim_value)) @@ -205,7 +205,7 @@ where /// - `usize` - A non-negative integer (`usize`). pub fn resize(&mut self, new_capacity: usize) { self.set_capacity(new_capacity); - while self.get_map().len() > *self.get_capacity() { + while self.get_map().len() > self.get_capacity() { if let Some(victim_key) = self.get_mut_order().pop_back() { self.get_mut_map().remove(&victim_key); } else { diff --git a/core/src/reactive/cache/struct.rs b/core/src/reactive/cache/struct.rs index f67beaa7..86d95f08 100644 --- a/core/src/reactive/cache/struct.rs +++ b/core/src/reactive/cache/struct.rs @@ -38,11 +38,10 @@ where { /// The maximum number of entries before eviction /// kicks in. - #[get(pub(crate))] - #[get(pub(crate))] + #[get(type(copy), pub(crate))] #[get_mut(pub(crate))] #[set(pub(crate))] - pub(crate) capacity: usize, + pub capacity: usize, /// The current entries, keyed by K. Default-initialised /// via `#[new(skip)]` (`HashMap::new()`). #[new(skip)] diff --git a/core/src/reactive/hook/struct.rs b/core/src/reactive/hook/struct.rs index 632700ff..a6f55a69 100644 --- a/core/src/reactive/hook/struct.rs +++ b/core/src/reactive/hook/struct.rs @@ -50,8 +50,7 @@ pub struct HookContext { #[derive(Clone, Copy, Data, Debug, Default, Eq, Hash, New, Ord, PartialEq, PartialOrd)] pub struct IntervalHandle { /// The interval ID assigned by the browser. - #[get(type(copy))] - #[get(pub(crate))] + #[get(type(copy), pub(crate))] #[get_mut(pub(crate))] #[set(pub(crate))] pub(crate) interval_id: i32, diff --git a/core/src/reactive/signal/struct.rs b/core/src/reactive/signal/struct.rs index 25c622ef..01c7493d 100644 --- a/core/src/reactive/signal/struct.rs +++ b/core/src/reactive/signal/struct.rs @@ -11,48 +11,48 @@ where /// The current value of the signal. #[debug(skip)] #[get(pub(crate))] - #[get_mut(pub(crate))] #[set(pub(crate))] + #[get_mut(pub(crate))] pub(crate) value: T, /// Callbacks to invoke when the value changes, each paired with its /// subscription id so [`Signal::unsubscribe`] can detach a single /// listener without disturbing the rest. #[debug(skip)] #[get(pub(crate))] - #[get_mut(pub(crate))] #[set(pub(crate))] + #[get_mut(pub(crate))] pub(crate) listeners: Vec, /// Monotonic counter backing subscription ids for `listeners`. - #[get(type(copy))] - #[get_mut(pub(crate))] - #[set(pub(crate))] #[new(skip)] + #[get(pub(crate), type(copy))] + #[set(pub(crate))] + #[get_mut(pub(crate))] pub(crate) next_listener_id: usize, /// Ids detached via [`Signal::unsubscribe`] while `update` had the /// listener list swapped out. Drained by `update`'s merge-back pass so /// a listener detached mid-notification is not resurrected. #[debug(skip)] + #[new(skip)] #[get(pub(crate))] - #[get_mut(pub(crate))] #[set(pub(crate))] - #[new(skip)] + #[get_mut(pub(crate))] pub(crate) removed_listener_ids: Vec, /// `true` while `update` has the listener list swapped out for /// notification. `unsubscribe` consults this flag to decide between /// direct removal and deferred removal via `removed_listener_ids`. - #[get(type(copy))] - #[get_mut(pub(crate))] - #[set(pub(crate))] #[new(skip)] + #[get(pub(crate), type(copy))] + #[set(pub(crate))] + #[get_mut(pub(crate))] pub(crate) notifying: bool, /// Whether this signal is still active. Set to `false` by `deactivate()` /// (and `clear_signal_listeners`) to make subsequent `set()` calls /// complete no-ops (no value update, no listener invocation, no /// dispatch scheduling), ensuring stale closures like orphaned /// `setInterval` handlers or pending `spawn_local` futures become harmless. - #[get(type(copy))] - #[get_mut(pub(crate))] + #[get(pub(crate), type(copy))] #[set(pub(crate))] + #[get_mut(pub(crate))] pub(crate) alive: bool, /// IDs of dynamic nodes that depend on this signal for precise dirty marking. /// When this signal changes, only these dynamic nodes are marked dirty @@ -60,6 +60,8 @@ where #[debug(skip)] #[get_mut(pub(crate))] #[new(skip)] + #[get(pub(crate))] + #[set(pub(crate))] pub(crate) dependents: Vec, } @@ -78,22 +80,25 @@ where T: Clone + PartialEq + 'static, { /// Slot index of the inner state in the global signal slab. + /// + /// The field is public because `get_inner` must stay public: + /// `macros/tests/watch` reads the slot address from outside this + /// crate to prove the signal handle is stable across a panic. A + /// public getter over a `pub(crate)` field is exactly the + /// `public-accessor-leaks-restricted-field` leak, so the field is + /// promoted to match rather than the getter narrowed. `set` / + /// `get_mut` stay crate-internal — the slot never moves. #[debug(skip)] #[get(type(copy))] - #[get_mut(pub(crate))] #[set(pub(crate))] #[get_mut(pub(crate))] - #[set(pub(crate))] - pub(crate) inner: usize, + pub inner: usize, /// Marker for the generic type parameter (uses fn pointer to be `Copy` /// regardless of `T`). #[debug(skip)] - #[get(type(copy))] - #[get_mut(pub(crate))] - #[set(pub(crate))] #[get(pub(crate))] - #[get_mut(pub(crate))] #[set(pub(crate))] + #[get_mut(pub(crate))] pub(crate) _marker: PhantomData T>, } @@ -111,13 +116,9 @@ where { /// Interior-mutable storage for an optional signal handle. #[debug(skip)] - #[get(pub(crate))] - #[get_mut(pub(crate))] #[set(pub(crate))] - #[get(pub(crate))] #[get_mut(pub(crate))] - #[set(pub(crate))] - pub(crate) inner: UnsafeCell>>, + pub inner: UnsafeCell>>, } /// Typed slab allocator for `SignalInner`. @@ -134,8 +135,8 @@ where pub(crate) struct SignalSlab { /// Slot storage. Index 0..len. #[get(pub(crate))] - #[get_mut(pub(crate))] #[set(pub(crate))] + #[get_mut(pub(crate))] pub(crate) entries: Vec>, } @@ -155,11 +156,8 @@ pub(crate) struct SignalSlab { #[derive(Clone, Copy, CustomDebug, Data, Eq, Hash, Ord, PartialEq, PartialOrd)] pub struct FireHandle { /// Address of the leaked `Box` allocation. - #[get(type(copy))] - #[get_mut(pub(crate))] + #[get(pub(crate), type(copy))] #[set(pub(crate))] - #[get(pub(crate))] #[get_mut(pub(crate))] - #[set(pub(crate))] pub(crate) inner: usize, } diff --git a/core/src/vdom/attribute/struct.rs b/core/src/vdom/attribute/struct.rs index 0ea7dd1f..735fb347 100644 --- a/core/src/vdom/attribute/struct.rs +++ b/core/src/vdom/attribute/struct.rs @@ -14,18 +14,18 @@ use super::*; #[derive(Clone, CustomDebug, Data, New)] pub struct AttributeEntry { /// The name of the attribute. + /// + /// `get_name` is public: `ui/tests/view_factories` collects attribute + /// names from outside this crate. `get_mut` / `set` stay crate-internal — + /// an attribute entry is built once by `New` and never rewritten. #[get_mut(pub(crate))] #[set(pub(crate))] - #[get_mut(pub(crate))] - #[set(pub(crate))] - pub(crate) name: Cow<'static, str>, + pub name: Cow<'static, str>, /// The value of the attribute. #[debug(skip)] #[get(pub(crate))] #[get_mut(pub(crate))] #[set(pub(crate))] - #[get_mut(pub(crate))] - #[set(pub(crate))] pub(crate) value: AttributeValue, } @@ -42,16 +42,12 @@ pub struct PseudoRule { #[get(pub(crate))] #[get_mut(pub(crate))] #[set(pub(crate))] - #[get_mut(pub(crate))] - #[set(pub(crate))] selector: String, /// The CSS style declarations for this pseudo rule /// (e.g., "background: rgba(79, 70, 229, 0.04); color: #4f46e5;"). #[get(pub(crate))] #[get_mut(pub(crate))] #[set(pub(crate))] - #[get_mut(pub(crate))] - #[set(pub(crate))] style: String, } @@ -63,30 +59,41 @@ pub struct PseudoRule { #[derive(Clone, Data, Debug, Default, New)] pub struct Css { /// The CSS class name used in the DOM. + /// + /// Public: the `class!` macro expands `get_name()` into consumer + /// crates (`ui`, `example`), and the component views in `ui` read it + /// directly, so the getter cannot be crate-private. The field is + /// public to match that getter — a public getter over a restricted + /// field is the leak `public-accessor-leaks-restricted-field` + /// reports. Mutation stays crate-internal. #[get_mut(pub(crate))] #[set(pub(crate))] - #[get_mut(pub(crate))] - #[set(pub(crate))] - name: String, + pub name: String, /// The CSS style declarations (e.g., "max-width: 800px; margin: 0 auto;"). + /// + /// Public for the same reason as [`Css::name`]: the `class!` macro + /// expands `get_style()` into consumer crates when a class extends + /// another class. Mutation stays crate-internal. #[get_mut(pub(crate))] #[set(pub(crate))] - #[get_mut(pub(crate))] - #[set(pub(crate))] - style: String, + pub style: String, /// The pseudo-class and pseudo-element rules for this class /// (e.g., ":hover", ":focus", ":active", "::before", etc.). + /// + /// Public for the same reason as [`Css::name`]: the `class!` macro + /// expands `get_pseudo_rules()` into consumer crates when a class + /// extends another class. Mutation stays crate-internal. #[get_mut(pub(crate))] #[set(pub(crate))] - #[get_mut(pub(crate))] - #[set(pub(crate))] - pseudo_rules: Vec, + pub pseudo_rules: Vec, /// The media query rules for this class. + /// + /// Public for the same reason as [`Css::name`]: the `class!` macro + /// expands `get_media_rules()` into consumer crates when a class + /// extends another class. Mutation stays crate-internal. #[get_mut(pub(crate))] #[set(pub(crate))] - #[get_mut(pub(crate))] - #[set(pub(crate))] - media_rules: Vec, + pub media_rules: Vec, } /// Represents a CSS @media rule attached to a class. @@ -101,25 +108,18 @@ pub struct MediaRule { #[get(pub(crate))] #[get_mut(pub(crate))] #[set(pub(crate))] - #[get(pub(crate))] - #[get_mut(pub(crate))] - #[set(pub(crate))] query: String, /// The CSS style declarations inside this media rule /// (e.g., "font-size: 14px; padding: 8px;"). #[get(pub(crate))] #[get_mut(pub(crate))] #[set(pub(crate))] - #[get_mut(pub(crate))] - #[set(pub(crate))] style: String, /// The pseudo-element rules nested inside this media rule /// (e.g., `::-webkit-scrollbar { width: "0px"; }`). #[get(pub(crate))] #[get_mut(pub(crate))] #[set(pub(crate))] - #[get_mut(pub(crate))] - #[set(pub(crate))] pseudo_rules: Vec, } @@ -137,13 +137,9 @@ pub struct MediaRule { #[derive(Data, Debug, New)] pub struct EventAdapter { /// The wrapped value to be adapted into an attribute. - #[get(pub(crate))] - #[get_mut(pub(crate))] - #[set(pub(crate))] - #[get(pub(crate))] #[get_mut(pub(crate))] #[set(pub(crate))] - pub(crate) inner: T, + pub inner: T, } /// Adapts an event with a specific event name into an `AttributeValue`. @@ -154,18 +150,11 @@ pub struct EventAdapter { #[derive(Data, Debug, New)] pub struct EventNamedAdapter { /// The wrapped event value to be adapted. - #[get(pub(crate))] - #[get_mut(pub(crate))] - #[set(pub(crate))] - #[get(pub(crate))] #[get_mut(pub(crate))] #[set(pub(crate))] - pub(crate) inner: T, + pub inner: T, /// The event name (e.g., "click", "mouseover"). - #[get(type(copy))] - #[get_mut(pub(crate))] - #[set(pub(crate))] - #[get(pub(crate))] + #[get(type(copy), pub(crate))] #[get_mut(pub(crate))] #[set(pub(crate))] pub(crate) event_name: &'static str, @@ -185,13 +174,9 @@ pub struct EventNamedAdapter { #[derive(Data, Debug, New)] pub struct AttrValueAdapter { /// The wrapped value to be adapted into an attribute. - #[get(pub(crate))] - #[get_mut(pub(crate))] - #[set(pub(crate))] - #[get(pub(crate))] #[get_mut(pub(crate))] #[set(pub(crate))] - pub(crate) inner: T, + pub inner: T, } /// Adapts an `inner_html:` payload into the matching `AttributeValue` @@ -213,13 +198,9 @@ pub struct AttrValueAdapter { pub struct InnerHtmlAdapter { /// The wrapped value to be adapted into an `AttributeValue` for /// the `inner_html:` attribute. - #[get(pub(crate))] - #[get_mut(pub(crate))] - #[set(pub(crate))] - #[get(pub(crate))] #[get_mut(pub(crate))] #[set(pub(crate))] - pub(crate) inner: T, + pub inner: T, } /// Adapts a callback with a custom name into an `AttributeValue`. @@ -230,18 +211,14 @@ pub struct InnerHtmlAdapter { #[derive(Data, Debug, New)] pub struct CallbackNamedAdapter { /// The wrapped callback to be adapted. - #[get(pub(crate))] - #[get_mut(pub(crate))] - #[set(pub(crate))] - #[get(pub(crate))] #[get_mut(pub(crate))] #[set(pub(crate))] - pub(crate) inner: T, + pub inner: T, /// The custom attribute name (e.g., "on-increment", "on-change"). #[get(type(copy))] #[get_mut(pub(crate))] #[set(pub(crate))] - pub(crate) name: &'static str, + pub name: &'static str, } /// Namespace for the injected-class registry accessors. diff --git a/core/src/vdom/node/struct.rs b/core/src/vdom/node/struct.rs index a6374be9..67aa3555 100644 --- a/core/src/vdom/node/struct.rs +++ b/core/src/vdom/node/struct.rs @@ -30,8 +30,6 @@ pub struct TextNode { #[get(pub(crate))] #[get_mut(pub(crate))] #[set(pub(crate))] - #[get_mut(pub(crate))] - #[set(pub(crate))] pub(crate) content: Cow<'static, str>, /// An optional binder that wires a freshly created DOM `Text` node to /// its backing signal. The binder is invoked once per DOM text node at @@ -44,9 +42,6 @@ pub struct TextNode { #[get(pub(crate))] #[get_mut(pub(crate))] #[set(pub(crate))] - #[get(pub(crate))] - #[get_mut(pub(crate))] - #[set(pub(crate))] pub(crate) binder: Option, } @@ -74,17 +69,11 @@ pub struct DynamicNode { #[get(pub(crate))] #[get_mut(pub(crate))] #[set(pub(crate))] - #[get(pub(crate))] - #[get_mut(pub(crate))] - #[set(pub(crate))] pub(crate) render_fn: Rc>, /// Persistent hook context for this dynamic node, storing signal /// state and other hook values across render cycles. #[get(pub(crate))] #[get_mut(pub(crate))] #[set(pub(crate))] - #[get(pub(crate))] - #[get_mut(pub(crate))] - #[set(pub(crate))] pub(crate) hook_context: HookContext, } diff --git a/core/src/vdom/raw/struct.rs b/core/src/vdom/raw/struct.rs index a8a8221d..5c32bcaa 100644 --- a/core/src/vdom/raw/struct.rs +++ b/core/src/vdom/raw/struct.rs @@ -17,9 +17,12 @@ use super::*; #[derive(Clone, Data, Debug, Default, Eq, Hash, New, PartialEq)] pub struct RawHtml { /// The unescaped HTML content. + /// + /// `get_content` is public — `core/tests/raw`, `core/tests/raw_html` and + /// `macros/tests/unsafe_no_inline` all read it from outside the crate. + /// `get_mut` / `set` stay crate-internal so the content is set at + /// construction and never mutated afterwards. #[get_mut(pub(crate))] #[set(pub(crate))] - #[get_mut(pub(crate))] - #[set(pub(crate))] - pub(crate) content: String, + pub content: String, } diff --git a/docs/Cargo.toml b/docs/Cargo.toml index cc0d4528..a74b2f32 100644 --- a/docs/Cargo.toml +++ b/docs/Cargo.toml @@ -15,7 +15,7 @@ exclude.workspace = true euv = { path = "..", version = "0" } euv-ui = { path = "../ui", version = "0" } -lombok-macros = "2.0" +lombok-macros = { workspace = true } [build-dependencies] serde_yaml = "0.9" diff --git a/docs/src/bin/docs/fn.rs b/docs/src/bin/docs/fn.rs index 697d2215..f440083a 100644 --- a/docs/src/bin/docs/fn.rs +++ b/docs/src/bin/docs/fn.rs @@ -232,7 +232,7 @@ fn build_one(args: &Args, out_dir: &Path) -> Result<(), String> { command.current_dir(&manifest_dir); command .arg(EUV_BUILD_SUBCMD) - .arg(if *args.get_release() { + .arg(if args.get_release() { EUV_RELEASE_FLAG } else { EUV_DEBUG_FLAG diff --git a/docs/src/bin/docs/struct.rs b/docs/src/bin/docs/struct.rs index 65aea8e5..bdf3fc59 100644 --- a/docs/src/bin/docs/struct.rs +++ b/docs/src/bin/docs/struct.rs @@ -19,7 +19,7 @@ pub(crate) struct Args { #[set(pub(crate))] name: &'static str, /// `true` → `euv build --release`, `false` → `euv build --debug`. - #[get(pub(crate))] + #[get(type(copy), pub(crate))] #[get_mut(pub(crate))] #[set(pub(crate))] release: bool, diff --git a/engine/src/asset/struct.rs b/engine/src/asset/struct.rs index c5e319c0..bc1646cb 100644 --- a/engine/src/asset/struct.rs +++ b/engine/src/asset/struct.rs @@ -9,17 +9,19 @@ pub struct AssetEntry { #[set(pub(crate))] pub(crate) asset_type: AssetType, /// The current loading state. - #[get(type(copy))] + #[get(pub(crate), type(copy))] #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) state: AssetState, /// The loaded image element, if this is an image asset. - #[get(type(clone))] + #[get(pub(crate), type(clone))] #[get_mut(pub(crate))] #[set(pub(crate))] pub(crate) image: Option, /// The URL this asset was loaded from. #[get_mut(pub(crate))] #[set(pub(crate))] + #[get(pub(crate))] pub(crate) url: String, } @@ -27,10 +29,13 @@ pub struct AssetEntry { #[derive(Clone, Data, Debug, New, PartialEq)] pub struct AssetCache { /// All cached assets keyed by their source URL. + /// + /// `get_entries` is public because tests/api_visibility/fn.rs reads this field from + /// outside this crate. `get_mut` / `set` stay crate-internal. #[get_mut(pub(crate))] #[set(pub(crate))] #[new(skip)] - pub(crate) entries: HashMap, + pub entries: HashMap, } /// An asynchronous asset loader that fetches resources over HTTP @@ -46,14 +51,20 @@ pub struct AssetLoader { /// The shared counter of loads that have been requested but have not /// settled yet. Read through [`AssetLoader::pending_count`]. #[new(skip)] + /// + /// `get_pending` is public because tests/asset/fn.rs reads this field from + /// outside this crate. `get_mut` / `set` stay crate-internal. #[get_mut(pub(crate))] #[set(pub(crate))] - pub(crate) pending: AssetPending, + pub pending: AssetPending, /// Stored closures keeping `onload`/`onerror` callbacks alive, preventing memory leaks. #[new(skip)] + /// + /// `get_closures` is public because tests/asset/fn.rs, tests/asset/mod.rs reads this field from + /// outside this crate. `get_mut` / `set` stay crate-internal. #[get_mut(pub(crate))] #[set(pub(crate))] - pub(crate) closures: AssetClosures, + pub closures: AssetClosures, } /// The backing store behind [`AssetClosures`]. diff --git a/engine/src/audio/impl.rs b/engine/src/audio/impl.rs index 039ae3f7..d73fd90a 100644 --- a/engine/src/audio/impl.rs +++ b/engine/src/audio/impl.rs @@ -18,11 +18,7 @@ impl GameAudioContext { let _: Result = master_gain.connect_with_audio_node(&context.destination()); master_gain.gain().set_value(AUDIO_DEFAULT_VOLUME as f32); - Some(GameAudioContext::new( - context, - master_gain, - AUDIO_DEFAULT_VOLUME, - )) + Some(GameAudioContext::new(context, master_gain)) } /// Sets the master volume for all audio output. @@ -73,18 +69,20 @@ impl GameAudioContext { impl AudioClip { /// Creates a new audio clip from a decoded buffer. /// + /// A clip carries no name: playback is driven by `play` / `stop`, which + /// address a clip by identity rather than by label, so a name field would + /// only ever be stored and never read. + /// /// # Arguments /// /// - `AudioBuffer` - The decoded audio data. - /// - `String` - The clip name. /// /// # Returns /// /// - `AudioClip` - The new clip. - pub fn create(buffer: AudioBuffer, name: String) -> AudioClip { + pub fn create(buffer: AudioBuffer) -> AudioClip { AudioClip::new( buffer, - name, AudioPlayState::Stopped, AUDIO_DEFAULT_LOOP, AUDIO_DEFAULT_VOLUME, diff --git a/engine/src/audio/struct.rs b/engine/src/audio/struct.rs index c611c829..1875dc36 100644 --- a/engine/src/audio/struct.rs +++ b/engine/src/audio/struct.rs @@ -7,17 +7,19 @@ pub struct GameAudioContext { /// The underlying Web Audio API context. #[set(pub(crate))] #[get_mut(pub(crate))] + #[get(pub(crate))] pub(crate) context: AudioContext, /// The master gain node controlling overall volume. + /// + /// The gain node's own `AudioParam` is the single source of truth for the + /// master level. There is deliberately no `master_volume` field beside it: + /// a second copy of the level could drift from the node it mirrors, and + /// nothing in the engine ever read it. Volume changes go through + /// [`GameAudioContext::apply_master_volume`]. #[set(pub(crate))] #[get(pub(crate))] #[get_mut(pub(crate))] pub(crate) master_gain: GainNode, - /// The master volume level in the range 0.0 to 1.0. - #[get(type(copy))] - #[get_mut(pub(crate))] - #[set(pub(crate))] - pub(crate) master_volume: f64, } /// An audio clip wrapping a decoded `AudioBuffer` with playback control. @@ -28,29 +30,38 @@ pub struct AudioClip { #[get(pub(crate))] #[get_mut(pub(crate))] pub(crate) buffer: AudioBuffer, - /// The name identifying this clip. - #[set(pub(crate))] - #[get_mut(pub(crate))] - pub(crate) name: String, /// The current playback state. - #[get(type(copy))] + /// + /// `get_state` is public because tests/audio_clip/fn.rs reads this field from + /// outside this crate. `get_mut` / `set` stay crate-internal. #[get_mut(pub(crate))] - pub(crate) state: AudioPlayState, - /// Whether the clip should loop when it reaches the end. #[get(type(copy))] + #[set(pub(crate))] + pub state: AudioPlayState, + /// Whether the clip should loop when it reaches the end. + /// + /// `get_looping` is public because tests/audio_clip/fn.rs reads this field from + /// outside this crate. `get_mut` / `set` stay crate-internal. #[get_mut(pub(crate))] + #[get(type(copy))] #[set(pub(crate))] - pub(crate) looping: bool, + pub looping: bool, /// The volume level in the range 0.0 to 1.0. - #[get(type(copy))] + /// + /// `get_volume` is public because tests/audio_clip/fn.rs reads this field from + /// outside this crate. `get_mut` / `set` stay crate-internal. #[get_mut(pub(crate))] + #[get(type(copy))] #[set(pub(crate))] - pub(crate) volume: f64, + pub volume: f64, /// The playback rate multiplier (1.0 = normal speed). - #[get(type(copy))] + /// + /// `get_playback_rate` is public because tests/audio_clip/fn.rs reads this field from + /// outside this crate. `get_mut` / `set` stay crate-internal. #[get_mut(pub(crate))] + #[get(type(copy))] #[set(pub(crate))] - pub(crate) playback_rate: f64, + pub playback_rate: f64, /// The currently active source node, if the clip is playing. #[get_mut(pub(crate))] #[set(pub(crate))] diff --git a/engine/src/collider/struct.rs b/engine/src/collider/struct.rs index 377813e2..d12b2f8a 100644 --- a/engine/src/collider/struct.rs +++ b/engine/src/collider/struct.rs @@ -5,15 +5,21 @@ use super::*; #[derive(Clone, Copy, Data, Debug, Default, New, PartialEq, PartialOrd)] pub struct CollisionResult { /// The minimum translation vector (MTV) pointing from the first collider to the second. - #[get(type(copy))] + /// + /// `get_normal` is public because tests/collider/fn.rs reads this field from + /// outside this crate. `get_mut` / `set` stay crate-internal. #[get_mut(pub(crate))] + #[get(type(copy))] #[set(pub(crate))] - pub(crate) normal: Vector2D, + pub normal: Vector2D, /// The penetration depth (how far the colliders overlap along the normal). - #[get(type(copy))] + /// + /// `get_depth` is public because tests/collider/fn.rs reads this field from + /// outside this crate. `get_mut` / `set` stay crate-internal. #[get_mut(pub(crate))] + #[get(type(copy))] #[set(pub(crate))] - pub(crate) depth: f64, + pub depth: f64, /// The contact point where the collision occurs. #[get(pub(crate), type(copy))] #[get_mut(pub(crate))] @@ -46,15 +52,21 @@ pub struct CircleCollider { #[derive(Clone, Copy, Data, Debug, Default, New, PartialEq, PartialOrd)] pub struct CollisionResult3D { /// The minimum translation vector (MTV) pointing from the first collider to the second. - #[get(type(copy))] + /// + /// `get_normal` is public because tests/collider/fn.rs reads this field from + /// outside this crate. `get_mut` / `set` stay crate-internal. #[get_mut(pub(crate))] + #[get(type(copy))] #[set(pub(crate))] - pub(crate) normal: Vector3D, + pub normal: Vector3D, /// The penetration depth (how far the colliders overlap along the normal). - #[get(type(copy))] + /// + /// `get_depth` is public because tests/collider/fn.rs reads this field from + /// outside this crate. `get_mut` / `set` stay crate-internal. #[get_mut(pub(crate))] + #[get(type(copy))] #[set(pub(crate))] - pub(crate) depth: f64, + pub depth: f64, /// The contact point where the collision occurs. #[get(pub(crate), type(copy))] #[get_mut(pub(crate))] diff --git a/engine/src/config/struct.rs b/engine/src/config/struct.rs index 5b79e100..756b8b56 100644 --- a/engine/src/config/struct.rs +++ b/engine/src/config/struct.rs @@ -10,27 +10,28 @@ pub struct RenderConfig { #[get(type(copy))] #[get_mut(pub(crate))] #[set(pub(crate))] - pub(crate) backend: RenderBackendType, + pub backend: RenderBackendType, /// The CSS selector used to query the canvas element from the DOM. #[get(type(clone))] #[get_mut(pub(crate))] #[set(pub(crate))] - pub(crate) canvas_selector: String, + pub canvas_selector: String, /// The logical viewport width in CSS pixels. #[get(type(copy))] #[get_mut(pub(crate))] - pub(crate) width: f64, + #[set(pub(crate))] + pub width: f64, /// The logical viewport height in CSS pixels. #[get(type(copy))] #[get_mut(pub(crate))] - pub(crate) height: f64, + #[set(pub(crate))] + pub height: f64, /// The rendering quality preset for canvas smoothing and SSAA downscaling. #[get(type(copy))] #[new(skip)] - #[get(pub(crate))] #[get_mut(pub(crate))] #[set(pub(crate))] - pub(crate) quality: RenderQuality, + pub quality: RenderQuality, /// Whether MSAA anti-aliasing is enabled for the WebGPU backend. /// /// Ignored when `backend` is `Canvas2D`. @@ -38,16 +39,15 @@ pub struct RenderConfig { #[new(skip)] #[get_mut(pub(crate))] #[set(pub(crate))] - pub(crate) antialias: bool, + pub antialias: bool, /// The power preference hint for WebGPU adapter selection. /// /// Ignored when `backend` is `Canvas2D`. #[get(type(copy))] #[new(skip)] - #[get(pub(crate))] #[get_mut(pub(crate))] #[set(pub(crate))] - pub(crate) power_preference: GpuPowerPreference, + pub power_preference: GpuPowerPreference, /// The SSAA supersampling scale factor for the Canvas 2D backend. /// /// A value of 2.0 renders at 4x resolution and downscales for smoother @@ -56,7 +56,7 @@ pub struct RenderConfig { #[new(skip)] #[get_mut(pub(crate))] #[set(pub(crate))] - pub(crate) ssaa_scale_factor: f64, + pub ssaa_scale_factor: f64, } /// The top-level engine configuration containing rendering and scheduler settings. @@ -68,11 +68,11 @@ pub struct EngineConfig { #[get(type(clone))] #[get_mut(pub(crate))] #[set(pub(crate))] - pub(crate) render: RenderConfig, + pub render: RenderConfig, /// The scheduler configuration controlling the fixed-timestep game loop. #[get(type(copy))] #[new(skip)] #[get_mut(pub(crate))] #[set(pub(crate))] - pub(crate) scheduler: SchedulerConfig, + pub scheduler: SchedulerConfig, } diff --git a/engine/src/engine/struct.rs b/engine/src/engine/struct.rs index 2abc1f09..4e38fbd8 100644 --- a/engine/src/engine/struct.rs +++ b/engine/src/engine/struct.rs @@ -25,19 +25,19 @@ pub struct EngineHandle { /// The engine configuration containing render and scheduler settings. #[get_mut(pub(crate))] #[set(pub(crate))] - pub(crate) config: EngineConfig, + pub config: EngineConfig, /// The initialized Canvas 2D renderer, or `None` if the WebGPU backend is used or initialization has not yet happened. - #[get(type(clone))] + #[get(pub(crate), type(clone))] #[get_mut(pub(crate))] #[set(pub(crate))] pub(crate) canvas_renderer: Option, /// The initialized WebGPU renderer, or `None` if the Canvas 2D backend is used or initialization has not yet happened. - #[get(type(clone))] + #[get(pub(crate), type(clone))] #[get_mut(pub(crate))] #[set(pub(crate))] pub(crate) webgpu_renderer: Option, /// The initialized WebGL 2 renderer, or `None` if another backend is used or initialization has not yet happened. - #[get(type(clone))] + #[get(pub(crate), type(clone))] #[get_mut(pub(crate))] #[set(pub(crate))] pub(crate) webgl_renderer: Option, @@ -47,7 +47,7 @@ pub struct EngineHandle { #[new(skip)] #[get_mut(pub(crate))] #[set(pub(crate))] - pub(crate) input_cell: Option, + pub input_cell: Option, /// The shared task registry holding every [`Updatable`] object the /// scheduler advances on each fixed step. Always present so callers can /// register tasks before `start` is called. diff --git a/engine/src/entity/struct.rs b/engine/src/entity/struct.rs index 67383187..4af9b594 100644 --- a/engine/src/entity/struct.rs +++ b/engine/src/entity/struct.rs @@ -5,30 +5,49 @@ use super::*; #[derive(Clone, Data, New)] pub struct Entity { /// The unique identifier of this entity. - #[get(type(copy))] + /// + /// `get_id` is public because tests/entity/fn.rs, tests/pool/fn.rs reads this field from + /// outside this crate. `get_mut` / `set` stay crate-internal. #[get_mut(pub(crate))] - pub(crate) id: u64, + #[get(type(copy))] + #[set(pub(crate))] + pub id: u64, /// The human-readable name of this entity. + /// + /// `get_name` is public because tests/entity/fn.rs, tests/pool/fn.rs, tests/remaining/fn.rs reads this field from + /// outside this crate. `get_mut` / `set` stay crate-internal. #[get_mut(pub(crate))] #[set(pub(crate))] - pub(crate) name: String, + pub name: String, /// The world-space transform (position, rotation, scale). - #[get(type(copy))] + /// + /// `get_transform` is public because tests/entity/fn.rs, tests/pool/fn.rs reads this field from + /// outside this crate. `get_mut` / `set` stay crate-internal. #[get_mut(pub(crate))] + #[get(type(copy))] #[set(pub(crate))] - pub(crate) transform: Transform2D, + pub transform: Transform2D, /// Whether this entity is active and should receive updates. - #[get(type(copy))] + /// + /// `get_active` / `set_active` are public because tests/entity/fn.rs, tests/pool/fn.rs reads this field from + /// outside this crate. `get_mut` / `set` stay crate-internal. #[get_mut(pub(crate))] - pub(crate) active: bool, + #[get(type(copy))] + pub active: bool, /// The list of components attached to this entity. + /// + /// `get_components` is public because tests/entity/fn.rs, tests/pool/fn.rs reads this field from + /// outside this crate. `get_mut` / `set` stay crate-internal. #[get_mut(pub(crate))] #[set(pub(crate))] - pub(crate) components: Vec, + pub components: Vec, /// Optional tags for grouping and querying entities. + /// + /// `get_tags` is public because tests/entity/fn.rs, tests/pool/fn.rs reads this field from + /// outside this crate. `get_mut` / `set` stay crate-internal. #[get_mut(pub(crate))] #[set(pub(crate))] - pub(crate) tags: Vec, + pub tags: Vec, } /// A publish-subscribe event bus for decoupled inter-entity communication. diff --git a/engine/src/input/struct.rs b/engine/src/input/struct.rs index fb97c2ad..da220cea 100644 --- a/engine/src/input/struct.rs +++ b/engine/src/input/struct.rs @@ -13,68 +13,104 @@ pub struct Input; #[derive(Clone, Data, Debug, New, PartialEq)] pub struct InputState { /// Key codes that were pressed during this frame. + /// + /// `get_keys_pressed` is public because tests/input/fn.rs reads this field from + /// outside this crate. `get_mut` / `set` stay crate-internal. #[get_mut(pub(crate))] - #[new(skip)] #[set(pub(crate))] - pub(crate) keys_pressed: KeyStateSet, + #[new(skip)] + pub keys_pressed: KeyStateSet, /// Key codes that are currently held down. + /// + /// `get_keys_held` is public because tests/input/fn.rs reads this field from + /// outside this crate. `get_mut` / `set` stay crate-internal. #[get_mut(pub(crate))] - #[new(skip)] #[set(pub(crate))] - pub(crate) keys_held: KeyStateSet, + #[new(skip)] + pub keys_held: KeyStateSet, /// Key codes that were released during this frame. + /// + /// `get_keys_released` is public because tests/input/fn.rs reads this field from + /// outside this crate. `get_mut` / `set` stay crate-internal. #[get_mut(pub(crate))] - #[new(skip)] #[set(pub(crate))] - pub(crate) keys_released: KeyStateSet, + #[new(skip)] + pub keys_released: KeyStateSet, /// Mouse buttons that were pressed during this frame. + /// + /// `get_mouse_buttons_pressed` is public because tests/input/fn.rs reads this field from + /// outside this crate. `get_mut` / `set` stay crate-internal. #[get_mut(pub(crate))] - #[new(skip)] #[set(pub(crate))] - pub(crate) mouse_buttons_pressed: HashSet, + #[new(skip)] + pub mouse_buttons_pressed: HashSet, /// Mouse buttons that are currently held down. + /// + /// `get_mouse_buttons_held` is public because tests/input/fn.rs reads this field from + /// outside this crate. `get_mut` / `set` stay crate-internal. #[get_mut(pub(crate))] - #[new(skip)] #[set(pub(crate))] - pub(crate) mouse_buttons_held: HashSet, + #[new(skip)] + pub mouse_buttons_held: HashSet, /// Mouse buttons that were released during this frame. + /// + /// `get_mouse_buttons_released` is public because tests/input/fn.rs reads this field from + /// outside this crate. `get_mut` / `set` stay crate-internal. #[get_mut(pub(crate))] - #[new(skip)] #[set(pub(crate))] - pub(crate) mouse_buttons_released: HashSet, + #[new(skip)] + pub mouse_buttons_released: HashSet, /// The current mouse position in screen coordinates. - #[get(type(copy))] + /// + /// `get_mouse_position` is public because tests/input/fn.rs, tests/input_state/fn.rs reads this field from + /// outside this crate. `get_mut` / `set` stay crate-internal. #[get_mut(pub(crate))] - #[new(skip)] + #[get(type(copy))] #[set(pub(crate))] - pub(crate) mouse_position: Vector2D, + #[new(skip)] + pub mouse_position: Vector2D, /// The mouse position delta (movement) since the last frame. - #[get(type(copy))] + /// + /// `get_mouse_delta` is public because tests/input/fn.rs, tests/input_state/fn.rs reads this field from + /// outside this crate. `get_mut` / `set` stay crate-internal. #[get_mut(pub(crate))] - #[new(skip)] + #[get(type(copy))] #[set(pub(crate))] - pub(crate) mouse_delta: Vector2D, + #[new(skip)] + pub mouse_delta: Vector2D, /// Whether the mouse has moved during this frame. - #[get(type(copy))] + /// + /// `get_mouse_moved` is public because tests/input/fn.rs reads this field from + /// outside this crate. `get_mut` / `set` stay crate-internal. #[get_mut(pub(crate))] - #[new(skip)] + #[get(type(copy))] #[set(pub(crate))] - pub(crate) mouse_moved: bool, + #[new(skip)] + pub mouse_moved: bool, /// Active touch points mapped by identifier to screen position. + /// + /// `get_touch_points` is public because tests/input/fn.rs reads this field from + /// outside this crate. `get_mut` / `set` stay crate-internal. #[get_mut(pub(crate))] - #[new(skip)] #[set(pub(crate))] - pub(crate) touch_points: TouchPointMap, + #[new(skip)] + pub touch_points: TouchPointMap, /// Touch point identifiers that started during this frame. + /// + /// `get_touch_started` is public because tests/input/fn.rs reads this field from + /// outside this crate. `get_mut` / `set` stay crate-internal. #[get_mut(pub(crate))] - #[new(skip)] #[set(pub(crate))] - pub(crate) touch_started: HashSet, + #[new(skip)] + pub touch_started: HashSet, /// Touch point identifiers that ended during this frame. + /// + /// `get_touch_ended` is public because tests/input/fn.rs reads this field from + /// outside this crate. `get_mut` / `set` stay crate-internal. #[get_mut(pub(crate))] - #[new(skip)] #[set(pub(crate))] - pub(crate) touch_ended: HashSet, + #[new(skip)] + pub touch_ended: HashSet, } /// Per-frame and steady-state data for a single gamepad index. @@ -89,8 +125,9 @@ pub struct InputState { pub struct GamepadState { /// The browser-reported identifier of the device, e.g. a mapping /// string such as `Xbox 360 Controller (XInput STANDARD GAMEPAD)`. - #[get(type(clone))] + #[get(pub(crate), type(clone))] #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) id: String, /// The current reading of every analog axis, indexed by axis number. /// Values outside the deadzone are stored as reported; the deadzone @@ -108,7 +145,7 @@ pub struct GamepadState { pub(crate) button_values: Vec, /// Whether the device is currently connected. A disconnected pad /// keeps its last `id` so callers can still identify it. - #[get(type(copy))] + #[get(pub(crate), type(copy))] #[get_mut(pub(crate))] #[set(pub(crate))] pub(crate) connected: bool, @@ -141,12 +178,16 @@ pub struct GamepadState { #[derive(Clone, Data, Debug, Default, PartialEq)] pub struct GamepadManager { /// Every gamepad state ever observed, keyed by gamepad index. + /// + /// `get_states` is public because tests/gamepad_poll/fn.rs reads this field from + /// outside this crate. `get_mut` / `set` stay crate-internal. #[get_mut(pub(crate))] #[set(pub(crate))] - pub(crate) states: GamepadStateMap, + pub states: GamepadStateMap, /// Gamepad indices that were first seen during this frame. #[get_mut(pub(crate))] #[set(pub(crate))] + #[get(pub(crate))] pub(crate) connected: GamepadIndexSet, /// Gamepad indices that were last seen leaving during this frame. #[get_mut(pub(crate))] diff --git a/engine/src/lighting/const.rs b/engine/src/lighting/const.rs index b3902a02..b3b2d8b6 100644 --- a/engine/src/lighting/const.rs +++ b/engine/src/lighting/const.rs @@ -25,3 +25,13 @@ pub(crate) const LIGHTING_DEFAULT_SHININESS: f64 = 32.0; /// Prevents a divide-by-zero when the shaded point is exactly at the light /// position. pub(crate) const LIGHTING_POINT_LIGHT_MIN_DISTANCE: f64 = 0.001; + +/// The distance handed to a [`LightingUniforms::shade_with_visibility`] +/// callback for a directional light. +/// +/// A directional light is infinitely far away, so it has no true distance. +/// The value is fixed at the ray tracer's `RAYTRACE_DEFAULT_T_MAX` (1000.0) +/// so a shadow ray cast toward a sun reaches the same far bound as the +/// primary rays in the same trace. Changing either constant without the +/// other makes directional shadow rays inconsistent with primary rays. +pub(crate) const LIGHTING_DIRECTIONAL_VISIBILITY_DISTANCE: f64 = 1000.0; diff --git a/engine/src/lighting/enum.rs b/engine/src/lighting/enum.rs index e6af8fac..37a70a29 100644 --- a/engine/src/lighting/enum.rs +++ b/engine/src/lighting/enum.rs @@ -1,16 +1,43 @@ use super::*; /// Describes the kind of light source being represented. +/// +/// ## Direction convention +/// +/// Every direction a [`Light`] carries is measured **toward the light +/// source**, with one deliberate exception: +/// +/// - [`LightType::Directional`]: `direction` points from the surface +/// toward the light (sourceward), so a surface is lit when +/// `dot(normal, direction) > 0`. +/// - [`LightType::Point`]: the direction is derived from +/// `position - surface_point` at shading time and is also sourceward; +/// the light stores no direction of its own. +/// - [`LightType::Spot`]: `direction` points **outward along the cone +/// axis**, away from the lamp, i.e. the way the light travels. The +/// surface→light vector used for shading is its negation. This is the +/// opposite sense from the other two kinds, so a spot light's cone axis +/// and a spot light's shading direction are *not* the same vector. #[derive(Clone, Copy, Debug, Default, PartialEq)] pub enum LightType { - /// An infinitely distant directional light (e.g. sunlight). The `direction` - /// field carries the unit vector pointing away from the light source. + /// An infinitely distant directional light (e.g. sunlight). + /// + /// The `direction` field carries the unit vector pointing from the + /// shaded surface toward the light source, matching the convention of + /// [`LightType::Point`]. #[default] Directional, /// A positional point light with inverse-square falloff. + /// + /// The light radiates in every direction; the surface→light vector is + /// computed from `position` at shading time. Point, /// A positional spotlight with a cone defined by a unit direction and a /// half-angle whose cosine is stored in the light's `spot_cos` field. + /// + /// The `direction` field is the cone **axis pointing outward** from the + /// lamp, in the direction the beam travels — the opposite sense to the + /// shading direction derived from `position`. Spot, } @@ -23,7 +50,7 @@ pub enum MaterialKind { /// [`LightingUniforms::shade`] evaluates the diffuse term only. #[default] Lambert, - /// Lambertian diffuse plus Blinn-Phong style specular highlight. + /// Lambertian diffuse plus a Phong specular highlight. /// /// [`LightingUniforms::shade`] evaluates the diffuse term plus a specular /// lobe driven by the material's `specular` and `shininess` fields. diff --git a/engine/src/lighting/fn.rs b/engine/src/lighting/fn.rs index d0572d60..d699b654 100644 --- a/engine/src/lighting/fn.rs +++ b/engine/src/lighting/fn.rs @@ -3,18 +3,18 @@ use super::*; /// Computes the Lambertian (diffuse) contribution of a single light at a /// shaded surface point. /// -/// The returned color is computed as: -/// /// ```text -/// color * intensity * max(0, dot(normal, light_dir)) * albedo +/// color * intensity * albedo * max(0, dot(normal, light_dir)) /// ``` /// -/// For directional lights, `light_dir` is the unit direction toward the -/// light. For point and spot lights, `light_dir` is the unit direction from -/// the surface position to the light position. The light's color and -/// intensity are merged multiplicatively with the Lambertian cosine term -/// and the material's albedo. The caller is expected to have already -/// applied any falloff factor. +/// `light_dir` is the unit direction from the surface toward the light; +/// any falloff must already be folded into a scaled intensity by the +/// caller. +/// +/// A thin wrapper over [`lambert_term`], which takes the same quantities +/// unpacked instead of through a [`Light`], so a direct call and the same +/// light routed through [`LightingUniforms::shade_with_visibility`] share +/// one implementation. /// /// # Arguments /// @@ -26,34 +26,66 @@ use super::*; /// /// - `Vector3D` - The diffuse contribution of this light. pub fn compute_lambert(light: &Light, normal: Vector3D, material: &Material) -> Vector3D { - let light_dir: Vector3D = light.get_direction(); + lambert_term( + light.get_color(), + light.get_intensity(), + light.get_direction(), + normal, + material.get_albedo(), + ) +} + +/// The Lambertian term shared by [`compute_lambert`] and +/// [`LightingUniforms::shade_with_visibility`]. +/// +/// Unpacking the light into its colour, intensity, and direction is what +/// lets the shading loop evaluate a light once — one distance, one +/// direction, one falloff — instead of building a mutated copy of the +/// [`Light`] per term. +/// +/// # Arguments +/// +/// - `Vector3D` - The light's RGB color. +/// - `f64` - The light's intensity, already scaled by any falloff. +/// - `Vector3D` - The unit direction from the surface toward the light. +/// - `Vector3D` - The surface normal (unit length). +/// - `Vector3D` - The material's diffuse albedo. +/// +/// # Returns +/// +/// - `Vector3D` - `color * albedo * intensity * max(0, dot(normal, light_dir))`. +pub(crate) fn lambert_term( + light_color: Vector3D, + light_intensity: f64, + light_dir: Vector3D, + normal: Vector3D, + albedo: Vector3D, +) -> Vector3D { let cos: f64 = normal.dot(light_dir).max(0.0); - let intensity: f64 = light.get_intensity(); - let color: Vector3D = light.get_color(); - let albedo: Vector3D = material.get_albedo(); - let k: f64 = intensity * cos; + let k: f64 = light_intensity * cos; Vector3D::new( - color.get_x() * albedo.get_x() * k, - color.get_y() * albedo.get_y() * k, - color.get_z() * albedo.get_z() * k, + light_color.get_x() * albedo.get_x() * k, + light_color.get_y() * albedo.get_y() * k, + light_color.get_z() * albedo.get_z() * k, ) } -/// Computes the Blinn-Phong specular contribution of a single light. -/// -/// Returns: +/// Computes the Phong specular contribution of a single light. /// /// ```text -/// light_color * intensity * pow(max(0, dot(reflect(-l, n), v)), shininess) * specular +/// color * intensity * specular * pow(max(0, dot(reflect(-l, n), v)), shininess) /// ``` /// -/// where `reflect(-l, n)` is the mirror reflection of the light direction -/// about the surface normal and `v` is the unit view direction from the -/// shaded point toward the eye. +/// where `l` is the unit direction from the surface toward the light, `n` +/// the surface normal, and `v` the unit view direction from the shaded +/// point toward the eye. A thin wrapper over [`phong_specular_term`], so +/// a direct call and the same light routed through +/// [`LightingUniforms::shade_with_visibility`] share one implementation. /// /// # Arguments /// -/// - `&Light` - The light source. +/// - `&Light` - The light source. Its `direction` must point from the +/// surface toward the light. /// - `Vector3D` - The surface normal (unit length). /// - `Vector3D` - The unit view direction (from the surface toward the eye). /// - `&Material` - The material at the shaded point. @@ -67,17 +99,101 @@ pub fn compute_phong( view_dir: Vector3D, material: &Material, ) -> Vector3D { - let light_dir: Vector3D = light.get_direction(); - let reflect: Vector3D = (light_dir - normal.scaled(2.0 * light_dir.dot(normal))).normalized(); - let spec_factor: f64 = reflect - .dot(view_dir) - .max(0.0) - .powf(material.get_shininess()); - let specular: f64 = material.get_specular(); - let intensity: f64 = light.get_intensity(); - let color: Vector3D = light.get_color(); - let k: f64 = intensity * spec_factor * specular; - Vector3D::new(color.get_x() * k, color.get_y() * k, color.get_z() * k) + phong_specular_term( + light.get_color(), + light.get_intensity(), + light.get_direction(), + normal, + view_dir, + material.get_specular(), + material.get_shininess(), + ) +} + +/// The Phong specular term shared by [`compute_phong`] and +/// [`LightingUniforms::shade_with_visibility`]. Returns the zero vector +/// unless the surface faces the light, so the lobe cannot survive on the +/// back side of a surface. +/// +/// # Arguments +/// +/// - `Vector3D` - The light's RGB color. +/// - `f64` - The light's intensity, already scaled by any falloff. +/// - `Vector3D` - The unit direction from the surface toward the light. +/// - `Vector3D` - The surface normal (unit length). +/// - `Vector3D` - The unit view direction (from the surface toward the eye). +/// - `f64` - The material's specular intensity. +/// - `f64` - The Phong specular exponent. +/// +/// # Returns +/// +/// - `Vector3D` - `color * intensity * specular * spec_factor`. +pub(crate) fn phong_specular_term( + light_color: Vector3D, + light_intensity: f64, + light_dir: Vector3D, + normal: Vector3D, + view_dir: Vector3D, + material_specular: f64, + shininess: f64, +) -> Vector3D { + let spec_factor: f64 = phong_specular_factor(light_dir, normal, view_dir, shininess); + let k: f64 = light_intensity * spec_factor * material_specular; + Vector3D::new( + light_color.get_x() * k, + light_color.get_y() * k, + light_color.get_z() * k, + ) +} + +/// Computes the Phong specular factor in the range 0.0..=1.0. +/// +/// The factor is `pow(max(0, dot(reflect(-l, n), v)), shininess)`. +/// +/// ## Why `-l` is reflected, not `l` +/// +/// `reflect(i, n) = i - 2 * dot(i, n) * n` mirrors an **incident** ray `i` +/// about the normal. The incident ray travels from the light into the +/// surface, so it is `-l`, and the expansion is +/// +/// ```text +/// reflect(-l, n) = -l + 2 * dot(n, l) * n +/// ``` +/// +/// Reflecting `l` instead yields a vector pointing back at the lamp, so +/// `dot(reflect, v)` peaks only where the eye is *behind* the surface and +/// the highlight lands on the unlit side. +/// +/// With `n` and `l` both unit length the reflection is already unit +/// length — `|2 * n * d - l|² = 4d² - 4d² + 1 = 1` for `d = dot(n, l)` — +/// so this needs no normalization, no division, and has no degenerate +/// case when `l` opposes `n`. The lobe peaks at `v == 2 * n * dot(n, l) - +/// l`, i.e. off-axis unless the light is exactly head-on. +/// +/// Returns `0.0` — never `NaN` — when `dot(n, l) <= 0.0`: a surface lit +/// from behind must not flash a highlight. +/// +/// # Arguments +/// +/// - `Vector3D` - The unit direction from the surface toward the light. +/// - `Vector3D` - The surface normal (unit length). +/// - `Vector3D` - The unit view direction (from the surface toward the eye). +/// - `f64` - The Phong specular exponent. +/// +/// # Returns +/// +/// - `f64` - The specular factor. +pub(crate) fn phong_specular_factor( + light_dir: Vector3D, + normal: Vector3D, + view_dir: Vector3D, + shininess: f64, +) -> f64 { + if normal.dot(light_dir) <= 0.0 { + return 0.0; + } + let reflected: Vector3D = normal.scaled(2.0 * normal.dot(light_dir)) - light_dir; + reflected.dot(view_dir).max(0.0).powf(shininess) } /// Applies the inverse-square falloff formula @@ -135,12 +251,101 @@ pub fn apply_schlick_fresnel(light_dir: Vector3D, view_dir: Vector3D, normal: Ve base + (1.0 - base) * (1.0 - cos).powi(5) } +/// Returns the unit-length form of `vector`, or the zero vector when it is +/// too short to have a meaningful direction. +/// +/// The threshold is [`EPSILON`], so a degenerate vector normalises to +/// zero rather than to `NaN` from a division by a vanishing magnitude. +/// A zero direction is safe downstream: it fails both the facing test and +/// the cone test. +/// +/// # Arguments +/// +/// - `Vector3D` - The vector to normalize. +/// +/// # Returns +/// +/// - `Vector3D` - The unit vector, or the zero vector at or below [`EPSILON`]. +pub(crate) fn unit_vector(vector: Vector3D) -> Vector3D { + unit_vector_and_length(vector).0 +} + +/// Returns `(unit(vector), length)` while taking the magnitude **once**. +/// +/// Shading needs both the unit direction and the original length of the +/// same vector; measuring it twice would duplicate the square root on the +/// hot path. A vector at or below [`EPSILON`] yields +/// `(zero, 0.0)`. +/// +/// # Arguments +/// +/// - `Vector3D` - The vector to normalize. +/// +/// # Returns +/// +/// - `(Vector3D, f64)` - The unit vector and the vector's own length. +pub(crate) fn unit_vector_and_length(vector: Vector3D) -> (Vector3D, f64) { + let length: f64 = vector.magnitude(); + if length <= EPSILON { + return (Vector3D::zero(), 0.0); + } + (vector.scaled(1.0 / length), length) +} + +/// Returns whether `light` can reach a surface point at all, before any +/// visibility query: the spot cone test plus a zero-intensity reject. +/// +/// These are the only two rejects a caller can make without geometry, so +/// [`LightingUniforms::shade_with_visibility`] applies them before +/// invoking the visibility callback. +/// +/// ## Why the cone test compares against `-spot_cos` +/// +/// A spot light stores `direction` as the cone axis pointing **outward** +/// along the beam, i.e. the way the light travels. `light_dir` points the +/// other way, from the surface **into** the lamp, so for a surface on the +/// axis `dot(light_dir, axis)` is `-1.0`, not `+1.0`. Being inside the cone +/// is therefore +/// +/// ```text +/// dot(light_dir, axis) <= -cos(half_angle) == -spot_cos +/// ``` +/// +/// Testing `dot(light_dir, axis) >= spot_cos` instead would invert the +/// cone: every point on the beam is rejected and every point behind the +/// lamp is lit. +/// +/// When the surface sits exactly on the lamp `light_dir` is zero, whose dot +/// product with any axis is `0.0`; for a cone of half-angle 90° or narrower +/// that would read as out of cone. The degenerate case is accepted +/// explicitly — a surface coincident with a lamp sits at the cone's apex +/// and is inside it by definition — and the zero `light_dir` makes the +/// facing test reject it regardless. +/// +/// # Arguments +/// +/// - `&Light` - The light being tested. +/// - `Vector3D` - The normalized direction from the surface toward the light. +/// +/// # Returns +/// +/// - `bool` - `false` when the light is out of cone or carries no intensity. +pub(crate) fn light_reaches_position(light: &Light, light_dir: Vector3D) -> bool { + if light.get_intensity() <= 0.0 { + return false; + } + if light.get_kind() != LightType::Spot { + return true; + } + if light_dir == Vector3D::zero() { + return true; + } + light_dir.dot(light.get_direction()) <= -light.get_spot_cos() +} + /// Intersects a ray with a sphere centered at `center` with radius `radius`. /// -/// Uses the standard quadratic-form ray-sphere test. Returns `Some((t, n))` -/// where `t` is the nearest positive intersection distance along the ray -/// and `n` is the outward unit normal at the hit point. Returns `None` if -/// the ray misses. +/// Uses the standard quadratic-form ray-sphere test. /// /// # Arguments /// @@ -241,12 +446,16 @@ pub fn ray_aabb_intersect( Some((t_near, t_far, normal)) } -/// Computes a soft-shadow visibility factor in the range 0.0..=1.0. +/// Computes a visibility factor in the range 0.0..=1.0 for the segment from +/// `origin` to `light_pos`. /// -/// Casts a single ray from `origin` toward `light_pos` and checks whether -/// any sphere in `occluders` blocks the path. Returns 1.0 when no occluder -/// is intersected, otherwise returns 0.0 (binary shadow). A future -/// refinement could sample multiple rays to approximate penumbra. +/// Despite the historical name this is **binary**, not soft: `1.0` when +/// nothing blocks the segment, `0.0` when anything does. No penumbra is +/// sampled. Callers that hold real geometry should use +/// [`LightingUniforms::shade_with_visibility`] with a callback instead; +/// this is the bounded fallback for callers holding only +/// `(center, radius)` bounding spheres, which necessarily over-shadow a +/// ray passing through empty space beside an occluder. /// /// # Arguments /// @@ -269,12 +478,52 @@ pub fn soft_shadow_factor( } let dir: Vector3D = to_light.scaled(1.0 / dist); for &(center, radius) in occluders.iter() { - if let Some((t, _)) = ray_sphere_intersect(origin, dir, center, radius) - && t > EPSILON - && t < dist - EPSILON - { + if segment_crosses_sphere(origin, dir, dist, center, radius) { return 0.0; } } 1.0 } + +/// Returns whether either ray-sphere root lands strictly inside the open +/// segment `(EPSILON, dist - EPSILON)`. +/// +/// **Both roots are tested, not just the near one.** A shadow ray is very +/// often cast from the surface point of one of the occluders themselves: +/// for that ray the near root sits at `t ≈ 0`, inside the [`EPSILON`] +/// self-hit threshold, while the far root sits at `t ≈ 2r`. Testing only +/// the nearest root would report the light as visible through the very +/// sphere the ray starts inside. +/// +/// # Arguments +/// +/// - `Vector3D` - The segment start (the shaded point). +/// - `Vector3D` - The unit direction toward the light. +/// - `f64` - The distance from the segment start to the light. +/// - `Vector3D` - The sphere center. +/// - `f64` - The sphere radius. +/// +/// # Returns +/// +/// - `bool` - `true` when the segment passes through the sphere's interior. +pub(crate) fn segment_crosses_sphere( + origin: Vector3D, + dir: Vector3D, + dist: f64, + center: Vector3D, + radius: f64, +) -> bool { + let oc: Vector3D = origin - center; + let b: f64 = oc.dot(dir); + let c: f64 = oc.dot(oc) - radius * radius; + let disc: f64 = b * b - c; + if disc < 0.0 { + return false; + } + let sq: f64 = disc.sqrt(); + let near: f64 = -b - sq; + let far: f64 = -b + sq; + let inside: f64 = EPSILON; + let outside: f64 = dist - EPSILON; + (near > inside && near < outside) || (far > inside && far < outside) +} diff --git a/engine/src/lighting/impl.rs b/engine/src/lighting/impl.rs index 9260b45d..a5b883fa 100644 --- a/engine/src/lighting/impl.rs +++ b/engine/src/lighting/impl.rs @@ -110,7 +110,7 @@ impl Material { ) } - /// Creates a Blinn-Phong material with the given albedo, specular + /// Creates a Phong material with the given albedo, specular /// strength, and specular exponent. /// /// # Arguments @@ -137,7 +137,8 @@ impl Material { /// The resulting material is shaded as a Lambertian diffuse term scaled /// by a Schlick fresnel factor; its `specular` and `shininess` fields are /// not consulted on that path. See [`MaterialKind::Pbr`] for what this - /// model does and does not include. + /// model does and does not include — in particular, read it as + /// "diffuse plus a fresnel rim", not as a physical BRDF. /// /// # Arguments /// @@ -170,8 +171,8 @@ impl Material { } } -/// Implements [`LightingUniforms`] builders and the [`LightingUniforms::shade`] -/// entry point used by the ray tracer. +/// Implements [`LightingUniforms`] builders and the shading entry points +/// used by the ray tracer and by any external visibility provider. impl LightingUniforms { /// Creates a uniform set with an empty light list, default ambient, /// and the supplied eye position. @@ -196,24 +197,31 @@ impl LightingUniforms { self.get_mut_lights().push(light); } - /// Shades a surface point by summing ambient, per-light Lambertian, and - /// per-light Phong contributions, gated by a soft shadow factor. - /// - /// The [`MaterialKind`] selects which terms are evaluated per light: - /// [`MaterialKind::Lambert`] contributes diffuse only, - /// [`MaterialKind::Phong`] adds a Blinn-Phong specular lobe, and - /// [`MaterialKind::Pbr`] adds an energy-conserving Schlick fresnel - /// factor to the diffuse term and no separate specular lobe. See - /// [`MaterialKind::Pbr`] for why that is an approximation and not a - /// full physically based model. + /// Shades a surface point against `(center, radius)` shadow bounding + /// spheres, the pre-existing entry point. + /// + /// A compatibility shim over + /// [`LightingUniforms::shade_with_visibility`] that supplies a callback + /// backed by [`soft_shadow_factor`], retained so existing ray-tracing + /// callers keep compiling unchanged. New code holding real geometry + /// should call [`LightingUniforms::shade_with_visibility`] directly and + /// get exact shadows instead of bounding-sphere ones. + /// + /// Directional lights report full visibility here, unchanged from + /// before: a bounding sphere stores a position but no direction, so + /// there is no finite segment to cast along and a callback using one + /// would have to invent a far bound. That far bound is + /// [`LIGHTING_DIRECTIONAL_VISIBILITY_DISTANCE`] and belongs to + /// [`LightingUniforms::shade_with_visibility`], where the caller + /// supplies its own geometry. /// /// # Arguments /// /// - `Vector3D` - The world-space position of the shaded point. /// - `Vector3D` - The surface normal (unit length). /// - `&Material` - The material at the shaded point. - /// - `&[(Vector3D, f64)]` - `(center, radius)` occluder tuples used by - /// [`soft_shadow_factor`]. + /// - `&[(Vector3D, f64)]` - `(center, radius)` occluder bounding + /// spheres, one per occluder. /// /// # Returns /// @@ -225,71 +233,177 @@ impl LightingUniforms { material: &Material, occluders: &[(Vector3D, f64)], ) -> Vector3D { + self.shade_with_visibility( + position, + normal, + material, + |light: &Light, _dir: Vector3D, _max_distance: f64| -> f64 { + match light.get_kind() { + LightType::Directional => 1.0, + LightType::Point | LightType::Spot => { + soft_shadow_factor(position, light.get_position(), occluders) + } + } + }, + ) + } + + /// Shades a surface point, asking `visibility` whether each light is + /// unoccluded along the segment to that light. + /// + /// The entry point for real shadow geometry. The callback receives the + /// light, the unit direction from the shaded point toward the light, + /// and the maximum distance to cast along that direction, and returns a + /// visibility factor that multiplies the light's whole contribution. + /// Returning `1.0` for every light reproduces the unshadowed result + /// exactly. + /// + /// ## The callback contract + /// + /// ```text + /// F: FnMut(&Light, surface_to_light_dir: Vector3D, max_distance: f64) -> f64 + /// ``` + /// + /// - `surface_to_light_dir` is the **normalized** direction from + /// `position` toward the light. For a spot light this points *into* + /// the lamp (it is the negation of the cone axis), matching the + /// shading convention used for every other term — cast your shadow + /// ray along this vector and it runs surface→light. + /// - `max_distance` is the far bound for that ray: the true + /// surface→light distance for point and spot lights, so the ray can + /// never reach past the lamp, and + /// [`LIGHTING_DIRECTIONAL_VISIBILITY_DISTANCE`] (1000.0, matching + /// the ray tracer's `RAYTRACE_DEFAULT_T_MAX`) for directional + /// lights, which have no distance of their own. + /// - The returned factor is clamped to `0.0..=1.0`. + /// + /// ## When the callback runs + /// + /// At most once per light, and only for lights that can actually + /// contribute. Lights are skipped before any callback work when they + /// carry no intensity, fall outside their spot cone, or face away from + /// the surface (`dot(normal, light_dir) <= 0`). Those tests are free, + /// and skipping them is the point of the ordering: back-facing and + /// out-of-cone lights are the common reject path. A light that is fully + /// occluded still costs one callback — only the callback knows the + /// geometry. + /// + /// ## Evaluation order + /// + /// Per light: cone test → facing test → compute `light_dir`, `dist`, + /// and `view_dir` **once** → visibility callback → falloff → + /// [`MaterialKind`] dispatch. The [`Light`] itself is only read, never + /// cloned: a point or spot light's derived direction and attenuating + /// intensity live in locals for the duration of the iteration, so the + /// diffuse, specular, and fresnel terms all read the same values. + /// + /// [`MaterialKind`] selects which terms run per light: + /// [`MaterialKind::Lambert`] contributes diffuse only, + /// [`MaterialKind::Phong`] adds a Phong specular lobe that is zero + /// unless the surface faces the light, and [`MaterialKind::Pbr`] + /// scales the diffuse term by a Schlick fresnel factor and adds no + /// separate specular lobe. + /// + /// Ambient is added once and is never shadowed; the material's + /// emissive term is added last and is likewise unaffected by + /// visibility. + /// + /// # Arguments + /// + /// - `Vector3D` - The world-space position of the shaded point. + /// - `Vector3D` - The surface normal (unit length). + /// - `&Material` - The material at the shaded point. + /// - `F` - The visibility provider, called once per contributing + /// light. + /// + /// # Returns + /// + /// - `Vector3D` - The final shaded color. + pub fn shade_with_visibility( + &self, + position: Vector3D, + normal: Vector3D, + material: &Material, + mut visibility: F, + ) -> Vector3D + where + F: FnMut(&Light, Vector3D, f64) -> f64, + { let mut color: Vector3D = self.get_ambient(); - let eye: Vector3D = self.get_eye(); - let to_eye: Vector3D = eye - position; - let view_dist: f64 = to_eye.magnitude(); - let view_dir: Vector3D = if view_dist > EPSILON { - to_eye.scaled(1.0 / view_dist) - } else { - Vector3D::zero() - }; + let view_dir: Vector3D = unit_vector(self.get_eye() - position); + let material_kind: MaterialKind = material.get_kind(); + let albedo: Vector3D = material.get_albedo(); for light in self.get_lights().iter() { let kind: LightType = light.get_kind(); - let shadow: f64 = match kind { - LightType::Directional => 1.0, + // Derived once per light and shared by every term below. The + // magnitude is taken once, alongside the unit direction, since + // both come from the same vector. The `Light` itself is only + // read, never cloned or mutated. + let (light_dir, distance): (Vector3D, f64) = match kind { + LightType::Directional => (light.get_direction(), 0.0), LightType::Point | LightType::Spot => { - soft_shadow_factor(position, light.get_position(), occluders) + unit_vector_and_length(light.get_position() - position) } }; - if shadow <= 0.0 { + // Free rejects first: a light that cannot reach this surface + // costs no visibility callback at all. + if !light_reaches_position(light, light_dir) { continue; } - let mut lambert_input: Light = light.clone(); - // The falloff distance is the light→surface distance, evaluated - // once and shared by every term. Directional lights have no - // distance to attenuate, so they keep their raw intensity. - let mut falloff_factor: f64 = 1.0; - match kind { - LightType::Directional => {} - LightType::Point | LightType::Spot => { - let to_light: Vector3D = light.get_position() - position; - let dist: f64 = to_light.magnitude().max(LIGHTING_POINT_LIGHT_MIN_DISTANCE); - let dir: Vector3D = to_light.scaled(1.0 / dist); - lambert_input.set_direction(dir); - falloff_factor = apply_falloff(dist, light.get_falloff()); - lambert_input.set_intensity(light.get_intensity() * falloff_factor); - } + // A back-facing surface has zero Lambert and zero specular, so + // culling here saves a shadow ray per light. + if normal.dot(light_dir) <= 0.0 { + continue; + } + // `max_distance` is the **true** surface→light distance, so a + // shadow ray can never reach past the lamp. Only the falloff + // distance is floored at `LIGHTING_POINT_LIGHT_MIN_DISTANCE`, + // to keep a coincident surface from dividing by zero; a + // directional light attenuates not at all and its shadow ray + // runs to the ray tracer's far bound. + let (falloff_factor, shadow_distance): (f64, f64) = match kind { + LightType::Directional => (1.0, LIGHTING_DIRECTIONAL_VISIBILITY_DISTANCE), + LightType::Point | LightType::Spot => ( + apply_falloff( + distance.max(LIGHTING_POINT_LIGHT_MIN_DISTANCE), + light.get_falloff(), + ), + distance, + ), + }; + let shadow: f64 = visibility(light, light_dir, shadow_distance).clamp(0.0, 1.0); + if shadow <= 0.0 { + continue; } - let material_kind: MaterialKind = material.get_kind(); + let intensity: f64 = light.get_intensity() * falloff_factor; + let light_color: Vector3D = light.get_color(); let diffuse: Vector3D = match material_kind { MaterialKind::Pbr => { - let base: Vector3D = compute_lambert(&lambert_input, normal, material); - let fresnel: f64 = - apply_schlick_fresnel(lambert_input.get_direction(), view_dir, normal); - base.scaled( + let fresnel: f64 = apply_schlick_fresnel(light_dir, view_dir, normal); + lambert_term(light_color, intensity, light_dir, normal, albedo).scaled( LIGHTING_PBR_ENERGY_CONSERVATION + (1.0 - LIGHTING_PBR_ENERGY_CONSERVATION) * fresnel, ) } MaterialKind::Lambert | MaterialKind::Phong => { - compute_lambert(&lambert_input, normal, material) + lambert_term(light_color, intensity, light_dir, normal, albedo) } }; let specular: Vector3D = match material_kind { MaterialKind::Lambert | MaterialKind::Pbr => Vector3D::zero(), - MaterialKind::Phong => { - let mut spec_input: Light = lambert_input.clone(); - spec_input.set_intensity(light.get_intensity() * falloff_factor); - compute_phong(&spec_input, normal, view_dir, material) - } + MaterialKind::Phong => phong_specular_term( + light_color, + intensity, + light_dir, + normal, + view_dir, + material.get_specular(), + material.get_shininess(), + ), }; - let mut contribution: Vector3D = diffuse + specular; - contribution = contribution.scaled(shadow); - color += contribution; + color += (diffuse + specular).scaled(shadow); } - let emissive: Vector3D = material.get_emissive(); - color += emissive; + color += material.get_emissive(); color } } diff --git a/engine/src/lighting/struct.rs b/engine/src/lighting/struct.rs index 49d2e6b4..adfdb01b 100644 --- a/engine/src/lighting/struct.rs +++ b/engine/src/lighting/struct.rs @@ -2,30 +2,40 @@ use super::*; /// A light source contributing illumination to a shaded point. /// -/// The fields have meanings that depend on [`LightType`]: +/// The fields have meanings that depend on [`LightType`]. Directions point +/// **toward the light source** except for the spot cone axis, which points +/// outward along the beam; see [`LightType`] for the full convention. /// /// - For [`LightType::Directional`], `position` is unused and `direction` is -/// the unit vector pointing from the surface toward the light. +/// the unit vector from the surface toward the light. /// - For [`LightType::Point`], `position` is the world position of the light /// and `direction` is unused. -/// - For [`LightType::Spot`], both `position` and `direction` are meaningful -/// and `spot_cos` carries `cos(half_angle)` of the spotlight cone. +/// - For [`LightType::Spot`], `position` is the lamp position, `direction` +/// is the cone axis pointing outward along the beam, and `spot_cos` +/// carries `cos(half_angle)` of the cone. +/// +/// A light is never mutated during shading: +/// [`LightingUniforms::shade_with_visibility`] reads it and derives +/// per-point quantities into locals. #[derive(Clone, Data, Debug, New, PartialEq)] pub struct Light { /// The kind of light source (directional, point, or spot). #[get(type(copy))] #[get_mut(pub(crate))] #[set(pub(crate))] - pub(crate) kind: LightType, - /// World-space position for point and spot lights; otherwise unused. + pub kind: LightType, + /// The world position of the light. #[get(type(copy))] #[get_mut(pub(crate))] #[set(pub(crate))] - pub(crate) position: Vector3D, - /// Unit direction for directional and spot lights; otherwise unused. + pub position: Vector3D, + /// Unit direction. Toward the light for [`LightType::Directional`]; + /// the outward cone axis for [`LightType::Spot`]; unused for + /// [`LightType::Point`]. #[get(type(copy))] #[get_mut(pub(crate))] - pub(crate) direction: Vector3D, + #[set(pub(crate))] + pub direction: Vector3D, /// RGB intensity multiplier applied to the contribution. #[get(pub(crate), type(copy))] #[get_mut(pub(crate))] @@ -35,22 +45,27 @@ pub struct Light { #[get(type(copy))] #[get_mut(pub(crate))] #[set(pub(crate))] - pub(crate) intensity: f64, + pub intensity: f64, /// Inverse-square falloff factor for point and spot lights. + /// + /// `pub` rather than `pub(crate)`: the getter is an out-of-crate + /// consumer's entry point (`tests/`, and the ray tracer's data stage), + /// and a narrowed field behind a public getter would silently + /// re-expose it. #[get(type(copy))] - pub(crate) falloff: f64, + pub falloff: f64, /// `cos(half_angle)` for spot lights; zero for other kinds. #[get(type(copy))] #[get_mut(pub(crate))] #[set(pub(crate))] - pub(crate) spot_cos: f64, + pub spot_cos: f64, } /// A material describing how a surface responds to illumination. #[derive(Clone, Data, Debug, New, PartialEq)] pub struct Material { /// The shading model applied during evaluation. - #[get(type(copy))] + #[get(pub(crate), type(copy))] #[get_mut(pub(crate))] #[set(pub(crate))] pub(crate) kind: MaterialKind, @@ -86,9 +101,13 @@ pub struct LightingUniforms { #[set(pub(crate))] pub(crate) lights: Vec, /// Ambient light contribution applied to every shaded point. + /// + /// The raytracing integration suites set the ambient term from outside + /// the crate, so the setter stays public; the getter and mut-getter have + /// no external consumer and stay crate-internal. #[get(pub(crate), type(copy))] #[get_mut(pub(crate))] - pub(crate) ambient: Vector3D, + pub ambient: Vector3D, /// View position used for specular term calculation. #[get(pub(crate), type(copy))] #[get_mut(pub(crate))] diff --git a/engine/src/math/struct.rs b/engine/src/math/struct.rs index 89cc9bd3..a4a8518d 100644 --- a/engine/src/math/struct.rs +++ b/engine/src/math/struct.rs @@ -14,11 +14,11 @@ pub struct Vector2D { /// The horizontal component of the vector. #[get(type(copy))] #[get_mut(pub(crate))] - pub(crate) x: f64, + pub x: f64, /// The vertical component of the vector. #[get(type(copy))] #[get_mut(pub(crate))] - pub(crate) y: f64, + pub y: f64, } /// An axis-aligned rectangle defined by its top-left corner and dimensions. @@ -27,19 +27,19 @@ pub struct Rect { /// The x coordinate of the top-left corner. #[get(type(copy))] #[get_mut(pub(crate))] - pub(crate) x: f64, + pub x: f64, /// The y coordinate of the top-left corner. #[get(type(copy))] #[get_mut(pub(crate))] - pub(crate) y: f64, + pub y: f64, /// The width of the rectangle. #[get(type(copy))] #[get_mut(pub(crate))] - pub(crate) width: f64, + pub width: f64, /// The height of the rectangle. #[get(type(copy))] #[get_mut(pub(crate))] - pub(crate) height: f64, + pub height: f64, } /// A circle defined by its center point and radius. @@ -49,12 +49,12 @@ pub struct Circle { #[get(type(copy))] #[get_mut(pub(crate))] #[set(pub(crate))] - pub(crate) center: Vector2D, + pub center: Vector2D, /// The radius of the circle. #[get(type(copy))] #[get_mut(pub(crate))] #[set(pub(crate))] - pub(crate) radius: f64, + pub radius: f64, } /// A 2D transform composed of position, rotation (in radians), and scale. @@ -63,8 +63,7 @@ pub struct Transform2D { /// The position offset of the transform. #[get(type(copy))] #[get_mut(pub(crate))] - #[set(pub(crate))] - pub(crate) position: Vector2D, + pub position: Vector2D, /// The rotation angle in radians. #[get(pub(crate), type(copy))] #[get_mut(pub(crate))] @@ -84,20 +83,20 @@ pub struct Color { #[get(type(copy))] #[get_mut(pub(crate))] #[set(pub(crate))] - pub(crate) red: f64, + pub red: f64, /// The green channel value in the range 0.0 to 1.0. #[get(type(copy))] #[get_mut(pub(crate))] #[set(pub(crate))] - pub(crate) green: f64, + pub green: f64, /// The blue channel value in the range 0.0 to 1.0. #[get(type(copy))] #[get_mut(pub(crate))] #[set(pub(crate))] - pub(crate) blue: f64, + pub blue: f64, /// The alpha (opacity) channel value in the range 0.0 to 1.0. #[get(type(copy))] - pub(crate) alpha: f64, + pub alpha: f64, } /// A 3D vector with `x`, `y`, and `z` components represented as `f64`. @@ -106,16 +105,15 @@ pub struct Vector3D { /// The horizontal component of the vector. #[get(type(copy))] #[get_mut(pub(crate))] - pub(crate) x: f64, + pub x: f64, /// The vertical component of the vector. #[get(type(copy))] #[get_mut(pub(crate))] - pub(crate) y: f64, + pub y: f64, /// The depth component of the vector. #[get(type(copy))] #[get_mut(pub(crate))] - #[set(pub(crate))] - pub(crate) z: f64, + pub z: f64, } /// A quaternion representing a 3D rotation with `x`, `y`, `z`, and `w` components. @@ -124,31 +122,33 @@ pub struct Quaternion { /// The x component of the quaternion. #[get(type(copy))] #[get_mut(pub(crate))] - pub(crate) x: f64, + pub x: f64, /// The y component of the quaternion. #[get(type(copy))] #[get_mut(pub(crate))] - pub(crate) y: f64, + pub y: f64, /// The z component of the quaternion. #[get(type(copy))] #[get_mut(pub(crate))] - #[set(pub(crate))] - pub(crate) z: f64, + pub z: f64, /// The w (scalar) component of the quaternion. #[get(type(copy))] #[get_mut(pub(crate))] #[set(pub(crate))] - pub(crate) w: f64, + pub w: f64, } /// A 4x4 matrix stored in column-major order, used for 3D transformations. #[derive(Clone, Copy, Data, Debug, New, PartialEq, PartialOrd)] pub struct Matrix4x4 { /// The 16 elements of the matrix in column-major order. - #[get(type(copy))] + /// + /// `get_elements` is public: `example/src/page/game_3d` reads the matrix + /// from outside this crate. `get_mut` / `set` stay crate-internal. #[get_mut(pub(crate))] + #[get(type(copy))] #[set(pub(crate))] - pub(crate) elements: [f64; 16], + pub elements: [f64; 16], } /// A 3D transform composed of position, rotation (as a quaternion), and scale. @@ -157,8 +157,7 @@ pub struct Transform3D { /// The position offset of the transform. #[get(type(copy))] #[get_mut(pub(crate))] - #[set(pub(crate))] - pub(crate) position: Vector3D, + pub position: Vector3D, /// The rotation as a quaternion. #[get(pub(crate), type(copy))] #[get_mut(pub(crate))] @@ -178,12 +177,12 @@ pub struct AABB3D { #[get(type(copy))] #[get_mut(pub(crate))] #[set(pub(crate))] - pub(crate) min: Vector3D, + pub min: Vector3D, /// The maximum corner (largest x, y, z). #[get(type(copy))] #[get_mut(pub(crate))] #[set(pub(crate))] - pub(crate) max: Vector3D, + pub max: Vector3D, } /// A 3D sphere defined by its center point and radius. @@ -193,24 +192,24 @@ pub struct Sphere { #[get(type(copy))] #[get_mut(pub(crate))] #[set(pub(crate))] - pub(crate) center: Vector3D, + pub center: Vector3D, /// The radius of the sphere. #[get(type(copy))] #[get_mut(pub(crate))] #[set(pub(crate))] - pub(crate) radius: f64, + pub radius: f64, } /// A 3D plane defined by a normal vector and a distance from the origin. #[derive(Clone, Copy, Data, Debug, Default, New, PartialEq, PartialOrd)] pub struct Plane { /// The normal vector of the plane (should be normalized). - #[get(type(copy))] + #[get(type(copy), pub(crate))] #[get_mut(pub(crate))] #[set(pub(crate))] pub(crate) normal: Vector3D, /// The signed distance from the origin along the normal. - #[get(type(copy))] + #[get(type(copy), pub(crate))] #[get_mut(pub(crate))] #[set(pub(crate))] pub(crate) distance: f64, @@ -223,11 +222,11 @@ pub struct Ray3D { #[get(type(copy))] #[get_mut(pub(crate))] #[set(pub(crate))] - pub(crate) origin: Vector3D, + pub origin: Vector3D, /// The direction vector of the ray (should be normalized). #[get(type(copy))] #[get_mut(pub(crate))] - pub(crate) direction: Vector3D, + pub direction: Vector3D, } /// A 2D ray defined by an origin point and a direction vector, used for @@ -238,9 +237,9 @@ pub struct Ray2D { #[get(type(copy))] #[get_mut(pub(crate))] #[set(pub(crate))] - pub(crate) origin: Vector2D, + pub origin: Vector2D, /// The direction vector of the ray (should be normalized). #[get(type(copy))] #[get_mut(pub(crate))] - pub(crate) direction: Vector2D, + pub direction: Vector2D, } diff --git a/engine/src/particle/struct.rs b/engine/src/particle/struct.rs index a096da76..f67c0128 100644 --- a/engine/src/particle/struct.rs +++ b/engine/src/particle/struct.rs @@ -13,7 +13,7 @@ pub struct ParticleRng { #[get(type(copy))] #[get_mut(pub(crate))] #[set(pub(crate))] - pub(crate) state: u64, + pub state: u64, } /// The static configuration of a particle emitter, describing how new @@ -25,52 +25,67 @@ pub struct ParticleRng { #[derive(Clone, Copy, Data, Debug, New, PartialEq, PartialOrd)] pub struct ParticleConfig { /// The number of particles spawned per second while the emitter is active. - #[get(type(copy))] - pub(crate) emission_rate: f64, + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + pub emission_rate: f64, /// The maximum number of simultaneously live particles. - #[get(type(copy))] - pub(crate) max_particles: usize, + #[get(pub(crate), type(copy))] + pub max_particles: usize, /// The minimum particle lifetime in seconds. #[get(type(copy))] - pub(crate) lifetime_min: f64, + pub lifetime_min: f64, /// The maximum particle lifetime in seconds. #[get(type(copy))] - pub(crate) lifetime_max: f64, + #[get_mut(pub(crate))] + pub lifetime_max: f64, /// The minimum initial particle speed in world units per second. - #[get(type(copy))] - pub(crate) speed_min: f64, + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + pub speed_min: f64, /// The maximum initial particle speed in world units per second. - #[get(type(copy))] - pub(crate) speed_max: f64, + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + pub speed_max: f64, /// The central emission direction in radians. - #[get(type(copy))] + #[get(pub(crate), type(copy))] #[new(skip)] + #[set(pub(crate))] + #[get_mut(pub(crate))] pub(crate) angle: f64, /// The total cone width around `angle` in radians within which /// particle directions are uniformly randomized. - #[get(type(copy))] + #[get(pub(crate), type(copy))] #[new(skip)] + #[set(pub(crate))] + #[get_mut(pub(crate))] pub(crate) spread: f64, /// The constant acceleration applied to every live particle. - #[get(type(copy))] + #[get(pub(crate), type(copy))] #[new(skip)] - pub(crate) gravity: Vector2D, + #[get_mut(pub(crate))] + pub gravity: Vector2D, /// The particle color at birth. - #[get(type(copy))] + #[get(pub(crate), type(copy))] #[new(skip)] + #[set(pub(crate))] + #[get_mut(pub(crate))] pub(crate) color_start: Color, /// The particle color at death. Set its alpha to 0.0 for a fade-out. - #[get(type(copy))] + #[get(pub(crate), type(copy))] #[new(skip)] + #[set(pub(crate))] + #[get_mut(pub(crate))] pub(crate) color_end: Color, /// The particle radius at birth in world units. - #[get(type(copy))] + #[get(pub(crate), type(copy))] #[new(skip)] - pub(crate) size_start: f64, + #[get_mut(pub(crate))] + pub size_start: f64, /// The particle radius at death in world units. - #[get(type(copy))] + #[get(pub(crate), type(copy))] #[new(skip)] - pub(crate) size_end: f64, + #[get_mut(pub(crate))] + pub size_end: f64, } /// A single live particle. @@ -79,13 +94,10 @@ pub struct Particle { /// The current world-space position. #[get(type(copy))] #[get_mut(pub(crate))] - #[set(pub(crate))] - pub(crate) position: Vector2D, - /// The current velocity in world units per second. - #[get(type(copy))] + pub position: Vector2D, + #[get(type(clone))] #[get_mut(pub(crate))] - #[set(pub(crate))] - pub(crate) velocity: Vector2D, + pub velocity: Vector2D, /// The time this particle has been alive, in seconds. #[get(pub(crate), type(copy))] #[get_mut(pub(crate))] @@ -95,7 +107,7 @@ pub struct Particle { #[get(type(copy))] #[set(pub(crate))] #[get_mut(pub(crate))] - pub(crate) lifetime: f64, + pub lifetime: f64, } /// A point emitter that continuously (or in bursts) spawns particles, @@ -106,37 +118,32 @@ pub struct ParticleEmitter { /// The world-space emission point. #[get(type(copy))] #[get_mut(pub(crate))] - #[set(pub(crate))] - pub(crate) position: Vector2D, + pub position: Vector2D, /// The emitter configuration. - #[get(type(copy))] - #[set(pub(crate))] + #[get(type(clone))] #[get_mut(pub(crate))] - pub(crate) config: ParticleConfig, + pub config: ParticleConfig, /// All currently live particles. #[get(type(clone))] #[get_mut(pub(crate))] #[set(pub(crate))] #[new(skip)] - #[get(pub(crate))] - pub(crate) particles: Vec, + pub particles: Vec, /// The fractional particle spawn budget carried between updates. #[get(type(copy))] #[get_mut(pub(crate))] #[set(pub(crate))] #[new(skip)] - #[get(pub(crate))] - pub(crate) emit_accumulator: f64, + pub emit_accumulator: f64, /// Whether continuous emission is currently enabled. - #[get(type(copy))] + #[get(pub(crate), type(copy))] #[new(skip)] #[get_mut(pub(crate))] - pub(crate) active: bool, + pub active: bool, /// The emitter's deterministic random generator. - #[get(type(copy))] + #[get(type(copy), pub(crate))] #[get_mut(pub(crate))] #[set(pub(crate))] #[new(skip)] - #[get(pub(crate))] pub(crate) rng: ParticleRng, } diff --git a/engine/src/physics/struct.rs b/engine/src/physics/struct.rs index c048108c..b559044c 100644 --- a/engine/src/physics/struct.rs +++ b/engine/src/physics/struct.rs @@ -4,8 +4,9 @@ use super::*; #[derive(Clone, Copy, Data, Debug, New, PartialEq, PartialOrd)] pub struct PhysicsConfig { /// The gravitational acceleration vector in pixels per second squared. - #[get(type(copy))] - pub(crate) gravity: Vector2D, + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + pub gravity: Vector2D, /// The linear velocity damping coefficient applied per second. #[get(pub(crate), type(copy))] #[get_mut(pub(crate))] @@ -24,27 +25,27 @@ pub struct RigidBody2D { /// The unique identifier of this body, typically matching a game object ID. #[get(type(copy))] #[get_mut(pub(crate))] - pub(crate) id: u64, + #[set(pub(crate))] + pub id: u64, /// The world-space position of the body's center. #[get(type(copy))] #[get_mut(pub(crate))] - pub(crate) position: Vector2D, + pub position: Vector2D, /// The linear velocity in pixels per second. #[get(type(copy))] #[get_mut(pub(crate))] #[new(skip)] - pub(crate) velocity: Vector2D, + pub velocity: Vector2D, /// The accumulated force to be applied during the next physics step. #[get(type(copy))] #[get_mut(pub(crate))] #[set(pub(crate))] #[new(skip)] - pub(crate) force_accumulator: Vector2D, + pub force_accumulator: Vector2D, /// The rotation angle in radians. - #[get(type(copy))] + #[get(type(copy), pub(crate))] #[get_mut(pub(crate))] #[new(skip)] - #[get(pub(crate))] #[set(pub(crate))] pub(crate) rotation: f64, /// The angular velocity in radians per second. @@ -52,17 +53,17 @@ pub struct RigidBody2D { #[get_mut(pub(crate))] #[new(skip)] #[set(pub(crate))] - pub(crate) angular_velocity: f64, + pub angular_velocity: f64, /// The mass of the body in kilograms. Static bodies have a mass of 0. #[get(type(copy))] #[get_mut(pub(crate))] #[set(pub(crate))] - pub(crate) mass: f64, + pub mass: f64, /// The precomputed inverse mass (1/mass). Static bodies have 0 inverse mass. #[get(type(copy))] #[get_mut(pub(crate))] #[set(pub(crate))] - pub(crate) inverse_mass: f64, + pub inverse_mass: f64, /// The restitution (bounciness) coefficient in the range 0.0 to 1.0. #[get(pub(crate), type(copy))] #[get_mut(pub(crate))] @@ -71,14 +72,15 @@ pub struct RigidBody2D { /// The friction coefficient for surface contact. #[get(pub(crate), type(copy))] #[get_mut(pub(crate))] - pub(crate) friction: f64, + pub friction: f64, /// How this body participates in the simulation. #[get(type(copy))] - pub(crate) body_type: BodyType, + #[set(pub(crate))] + #[get_mut(pub(crate))] + pub body_type: BodyType, /// The collider shape attached to this body. - #[get(type(copy))] + #[get(type(copy), pub(crate))] #[new(skip)] - #[get(pub(crate))] #[get_mut(pub(crate))] #[set(pub(crate))] pub(crate) collider: Option, @@ -91,18 +93,18 @@ pub struct PhysicsWorld2D { #[get_mut(pub(crate))] #[set(pub(crate))] #[new(skip)] - pub(crate) bodies: Vec, + pub bodies: Vec, /// The simulation configuration. #[get(type(copy))] #[get_mut(pub(crate))] #[set(pub(crate))] - pub(crate) config: PhysicsConfig, + pub config: PhysicsConfig, /// The persistent broad-phase grid, cleared and re-inserted each step so its /// `HashMap` allocation is reused instead of rebuilt per step. #[get_mut(pub(crate))] #[set(pub(crate))] #[new(skip)] - pub(crate) grid: SpatialHashGrid2D, + pub grid: SpatialHashGrid2D, /// Scratch buffer reused by grid queries to avoid a per-query `Vec` allocation. #[get_mut(pub(crate))] #[new(skip)] @@ -130,8 +132,7 @@ pub struct PhysicsConfig3D { /// The gravitational acceleration vector in meters per second squared. #[get(pub(crate), type(copy))] #[get_mut(pub(crate))] - #[set(pub(crate))] - pub(crate) gravity: Vector3D, + pub gravity: Vector3D, /// The linear velocity damping coefficient applied per second. #[get(pub(crate), type(copy))] #[get_mut(pub(crate))] @@ -150,28 +151,27 @@ pub struct RigidBody3D { /// The unique identifier of this body, typically matching a game object ID. #[get(type(copy))] #[get_mut(pub(crate))] - pub(crate) id: u64, + #[set(pub(crate))] + pub id: u64, /// The world-space position of the body's center. #[get(type(copy))] #[get_mut(pub(crate))] - #[set(pub(crate))] - pub(crate) position: Vector3D, + pub position: Vector3D, /// The linear velocity in meters per second. #[get(type(copy))] #[get_mut(pub(crate))] #[new(skip)] - pub(crate) velocity: Vector3D, + pub velocity: Vector3D, /// The accumulated force to be applied during the next physics step. #[get(type(copy))] #[get_mut(pub(crate))] #[set(pub(crate))] #[new(skip)] - pub(crate) force_accumulator: Vector3D, + pub force_accumulator: Vector3D, /// The rotation as a quaternion. - #[get(type(copy))] + #[get(type(copy), pub(crate))] #[get_mut(pub(crate))] #[new(skip)] - #[get(pub(crate))] #[set(pub(crate))] pub(crate) rotation: Quaternion, /// The angular velocity as a 3D vector (axis * speed). @@ -179,23 +179,23 @@ pub struct RigidBody3D { #[get_mut(pub(crate))] #[new(skip)] #[set(pub(crate))] - pub(crate) angular_velocity: Vector3D, + pub angular_velocity: Vector3D, /// The accumulated torque to be applied during the next physics step. #[get(type(copy))] #[get_mut(pub(crate))] #[set(pub(crate))] #[new(skip)] - pub(crate) torque_accumulator: Vector3D, + pub torque_accumulator: Vector3D, /// The mass of the body in kilograms. Static bodies have a mass of 0. #[get(type(copy))] #[get_mut(pub(crate))] #[set(pub(crate))] - pub(crate) mass: f64, + pub mass: f64, /// The precomputed inverse mass (1/mass). Static bodies have 0 inverse mass. #[get(type(copy))] #[get_mut(pub(crate))] #[set(pub(crate))] - pub(crate) inverse_mass: f64, + pub inverse_mass: f64, /// The precomputed inverse moment of inertia (scalar, axis-aligned). Static /// bodies have 0 inverse inertia. Used to convert accumulated torque into /// angular velocity each step. @@ -203,7 +203,7 @@ pub struct RigidBody3D { #[get_mut(pub(crate))] #[set(pub(crate))] #[new(skip)] - pub(crate) inverse_inertia: f64, + pub inverse_inertia: f64, /// The restitution (bounciness) coefficient in the range 0.0 to 1.0. #[get(pub(crate), type(copy))] #[get_mut(pub(crate))] @@ -212,14 +212,15 @@ pub struct RigidBody3D { /// The friction coefficient for surface contact. #[get(pub(crate), type(copy))] #[get_mut(pub(crate))] - pub(crate) friction: f64, + pub friction: f64, /// How this body participates in the simulation. #[get(type(copy))] - pub(crate) body_type: BodyType, + #[set(pub(crate))] + #[get_mut(pub(crate))] + pub body_type: BodyType, /// The 3D collider shape attached to this body. - #[get(type(copy))] + #[get(type(copy), pub(crate))] #[new(skip)] - #[get(pub(crate))] #[get_mut(pub(crate))] #[set(pub(crate))] pub(crate) collider: Option, @@ -232,18 +233,18 @@ pub struct PhysicsWorld3D { #[get_mut(pub(crate))] #[set(pub(crate))] #[new(skip)] - pub(crate) bodies: Vec, + pub bodies: Vec, /// The simulation configuration. #[get(type(copy))] #[get_mut(pub(crate))] #[set(pub(crate))] - pub(crate) config: PhysicsConfig3D, + pub config: PhysicsConfig3D, /// The persistent broad-phase grid, cleared and re-inserted each step so its /// `HashMap` allocation is reused instead of rebuilt per step. #[get_mut(pub(crate))] #[set(pub(crate))] #[new(skip)] - pub(crate) grid: SpatialHashGrid3D, + pub grid: SpatialHashGrid3D, /// Scratch buffer reused by grid queries to avoid a per-query `Vec` allocation. #[get_mut(pub(crate))] #[new(skip)] diff --git a/engine/src/raytracing/backend/canvas/const.rs b/engine/src/raytracing/backend/canvas/const.rs new file mode 100644 index 00000000..1d1c06a2 --- /dev/null +++ b/engine/src/raytracing/backend/canvas/const.rs @@ -0,0 +1,15 @@ +/// The blit origin: a frame always covers the whole surface, so the +/// image is written from the canvas' top-left corner with no offset. +pub(crate) const CANVAS_BLIT_ORIGIN: f64 = 0.0; + +/// The error reported when a traced frame does not cover exactly one +/// surface. +/// +/// A frame that is too short is a truncation and a frame that is too +/// long is a stale buffer; neither is ever clipped to fit, because a +/// silently clipped frame still produces a picture. +pub(crate) const ERROR_CANVAS_PIXEL_LENGTH: &str = + "the traced pixel buffer does not cover exactly one surface"; + +/// The error reported when a released backend is driven again. +pub(crate) const ERROR_CANVAS_RELEASED: &str = "the Canvas 2D backend has already been released"; diff --git a/engine/src/raytracing/backend/canvas/fn.rs b/engine/src/raytracing/backend/canvas/fn.rs new file mode 100644 index 00000000..74f71417 --- /dev/null +++ b/engine/src/raytracing/backend/canvas/fn.rs @@ -0,0 +1,104 @@ +use super::*; + +/// Acquires the 2D context for a canvas and reports why it failed. +/// +/// # Arguments +/// +/// - `&HtmlCanvasElement` - The canvas to draw into. +/// +/// # Returns +/// +/// - `Result` - The context, or the +/// reason the browser refused one. +pub(crate) fn attach_canvas_context( + canvas: &HtmlCanvasElement, +) -> Result { + let lookup: Result, JsValue> = canvas.get_context(CANVAS_CONTEXT_ID_2D); + let context_object: Object = match lookup { + Ok(Some(object)) => object, + Ok(None) => { + return Err(backend_error( + ERROR_CANVAS_UNAVAILABLE, + ERROR_CANVAS_UNAVAILABLE.to_string(), + )); + } + Err(reason) => return Err(backend_error(ERROR_CANVAS_UNAVAILABLE, describe(&reason))), + }; + match context_object.dyn_into() { + Ok(context) => Ok(context), + Err(_) => Err(backend_error( + ERROR_CANVAS_UNAVAILABLE, + ERROR_CANVAS_UNAVAILABLE.to_string(), + )), + } +} + +/// Allocates the surface's image and the array the image reads. +/// +/// The two are built together because their lengths must agree by +/// construction: the array is allocated for exactly one surface, and +/// the `ImageData` is constructed over that exact array. A size the +/// browser refuses therefore fails here, before any state is changed. +/// +/// # Arguments +/// +/// - `u32` - The surface width in pixels. +/// - `u32` - The surface height in pixels. +/// +/// # Returns +/// +/// - `Result<(ImageData, Uint8ClampedArray), JsValue>` - The image and +/// its backing store, or the reason the browser refused them. +pub(crate) fn allocate_canvas_surface( + width: u32, + height: u32, +) -> Result<(ImageData, Uint8ClampedArray), JsValue> { + let expected: usize = match surface_rgba_length(width, height) { + Some(bytes) => bytes, + None => { + return Err(backend_error( + ERROR_DIMENSION_OVERFLOW, + format!("{width}x{height} overflows the addressable byte count"), + )); + } + }; + let length: u32 = match u32::try_from(expected) { + Ok(elements) => elements, + Err(_) => { + return Err(backend_error( + ERROR_DIMENSION_OVERFLOW, + format!("{width}x{height} needs {expected} bytes, over one typed array"), + )); + } + }; + let backing: Uint8ClampedArray = Uint8ClampedArray::new_with_length(length); + let image: ImageData = + match ImageData::new_with_js_u8_clamped_array_and_sh(&backing, width, height) { + Ok(value) => value, + Err(reason) => return Err(backend_error(ERROR_CANVAS_IMAGE_DATA, describe(&reason))), + }; + Ok((image, backing)) +} + +/// The RGBA byte length of one surface, or `None` when the size cannot +/// be addressed. +/// +/// The multiplication is checked on both axes: a size that overflows +/// is not clamped into a smaller picture, it is refused, because a +/// silently clamped frame and a correctly sized frame are +/// indistinguishable on screen. +/// +/// # Arguments +/// +/// - `u32` - The surface width in pixels. +/// - `u32` - The surface height in pixels. +/// +/// # Returns +/// +/// - `Option` - `width * height * 4`, or `None` when that +/// count overflows. +pub(crate) fn surface_rgba_length(width: u32, height: u32) -> Option { + (width as usize) + .checked_mul(height as usize)? + .checked_mul(4) +} diff --git a/engine/src/raytracing/backend/canvas/impl.rs b/engine/src/raytracing/backend/canvas/impl.rs new file mode 100644 index 00000000..c654f2d9 --- /dev/null +++ b/engine/src/raytracing/backend/canvas/impl.rs @@ -0,0 +1,150 @@ +use super::*; + +impl CanvasRayTraceBackend { + /// Acquires the 2D context and allocates the surface's image and + /// backing array. + /// + /// # Arguments + /// + /// - `&HtmlCanvasElement` - The canvas to draw into. + /// - `u32` - The surface width in pixels. + /// - `u32` - The surface height in pixels. + /// + /// # Returns + /// + /// - `Result` - The ready backend, or the reason the + /// browser refused a 2D context or an image. + pub fn attach(canvas: &HtmlCanvasElement, width: u32, height: u32) -> Result { + let context: CanvasRenderingContext2d = attach_canvas_context(canvas)?; + let (image, backing): (ImageData, Uint8ClampedArray) = + allocate_canvas_surface(width, height)?; + Ok(Self { + context, + image, + backing, + image_width: width, + image_height: height, + }) + } + + /// The largest per-axis surface length this backend accepts. + /// + /// Canvas 2D exposes no driver limit of its own: the browser + /// rejects an oversized surface through the `ImageData` + /// constructor, which is a per-call error, not a queryable + /// budget. Reporting `None` leaves that rejection to the one place + /// that can actually observe it. + /// + /// # Returns + /// + /// - `Option` - `None`, because no Canvas 2D limit exists to + /// enforce up front. + pub fn dimension_limit(&self) -> Option { + None + } + + /// Reallocates the surface only when the size actually changed. + /// + /// The new image and backing array are allocated first and swapped + /// in only once the allocation has been accepted, so a rejected + /// size leaves the previous, still renderable, surface untouched. + /// The canvas element's own width and height are the caller's to + /// commit: this backend never depends on them having changed + /// already. + /// + /// # Arguments + /// + /// - `u32` - The surface width in pixels. + /// - `u32` - The surface height in pixels. + /// + /// # Returns + /// + /// - `Result<(), JsValue>` - `Ok` once the surface is sized. + pub fn resize(&mut self, width: u32, height: u32) -> Result<(), JsValue> { + if self.get_image_width() == width && self.get_image_height() == height { + return Ok(()); + } + let (image, backing): (ImageData, Uint8ClampedArray) = + allocate_canvas_surface(width, height)?; + self.set_image(image); + self.set_backing(backing); + self.set_image_width(width); + self.set_image_height(height); + Ok(()) + } + + /// Copies the traced RGBA bytes into the reused image and blits it. + /// + /// The bytes go through the backing array's own `copy_from`, which + /// writes the JavaScript `Uint8ClampedArray` the `ImageData` was + /// constructed over. `ImageData::data` cannot be used for this: + /// its `Clamped>` is a Rust copy, so writing into it leaves + /// the image's own store untouched and the blit presents the + /// original transparent pixels. + /// + /// The length is exact. A buffer that no longer matches the surface + /// is an error rather than a truncated or padded frame, because a + /// clipped upload still produces a picture that only a pixel + /// comparison can catch. + /// + /// # Arguments + /// + /// - `&[u8]` - The traced pixels, one RGBA byte per channel. + /// + /// # Returns + /// + /// - `Result<(), JsValue>` - `Ok` once the blit was accepted. + pub fn present(&mut self, pixels: &[u8]) -> Result<(), JsValue> { + let width: u32 = self.get_image_width(); + let height: u32 = self.get_image_height(); + if width == 0 || height == 0 { + return Err(backend_error( + ERROR_CANVAS_RELEASED, + ERROR_CANVAS_RELEASED.to_string(), + )); + } + let expected: usize = match surface_rgba_length(width, height) { + Some(bytes) => bytes, + None => { + return Err(backend_error( + ERROR_DIMENSION_OVERFLOW, + format!("{width}x{height} overflows the addressable byte count"), + )); + } + }; + if pixels.len() != expected { + return Err(backend_error( + ERROR_CANVAS_PIXEL_LENGTH, + format!("{} bytes for {expected} byte surface", pixels.len()), + )); + } + self.get_backing().copy_from(pixels); + match self.get_context().put_image_data( + self.get_image(), + CANVAS_BLIT_ORIGIN, + CANVAS_BLIT_ORIGIN, + ) { + Ok(()) => Ok(()), + Err(reason) => Err(backend_error(ERROR_CANVAS_PUT, describe(&reason))), + } + } + + /// Marks the surface as released. + /// + /// The `ImageData`, its backing array, and the 2D context are + /// JavaScript objects owned by this struct, so they are reclaimed + /// when the adapter holding the backend is dropped; there is no + /// explicit teardown call for any of them. What release does own + /// is the surface size: zeroing it means a later `present` is + /// rejected on the length check instead of blitting a surface the + /// caller has already torn down. The 2D context belongs to the + /// canvas, so it is never destroyed here. + /// + /// # Returns + /// + /// - `()` - Cleanup never fails. + pub fn release(&mut self) { + self.set_image_width(0); + self.set_image_height(0); + } +} diff --git a/engine/src/raytracing/backend/canvas/mod.rs b/engine/src/raytracing/backend/canvas/mod.rs new file mode 100644 index 00000000..3fbb2016 --- /dev/null +++ b/engine/src/raytracing/backend/canvas/mod.rs @@ -0,0 +1,10 @@ +mod r#const; +mod r#fn; +mod r#impl; +mod r#struct; + +pub use r#struct::*; + +pub(crate) use {r#const::*, r#fn::*}; + +use super::*; diff --git a/engine/src/raytracing/backend/canvas/struct.rs b/engine/src/raytracing/backend/canvas/struct.rs new file mode 100644 index 00000000..a1f3c597 --- /dev/null +++ b/engine/src/raytracing/backend/canvas/struct.rs @@ -0,0 +1,49 @@ +use super::*; + +/// The Canvas 2D reference backend: a CPU trace blitted through one +/// reused `ImageData`. +/// +/// The surface is two JavaScript objects, not one: the `ImageData` +/// the browser reads at blit time, and the `Uint8ClampedArray` that is +/// its pixel backing store. Both are allocated once per surface size +/// and reused by every later frame. +/// +/// The two are distinct on purpose. `ImageData::data` is declared in +/// `web-sys` as `Clamped>`, which is a Rust copy of the +/// JavaScript array: writing into that copy cannot change the array the +/// `ImageData` still points at, so a blit after such a write is a +/// blit of the original, still transparent, pixels. Uploading through +/// the backing array's own `copy_from` is what actually reaches the +/// image, and it copies the traced bytes straight out of linear memory +/// without an intermediate Rust `Vec`. +#[derive(Debug, Data)] +pub struct CanvasRayTraceBackend { + /// The 2D context every blit goes through. + #[get(pub(crate))] + #[get_mut(pub(crate))] + #[set(pub(crate))] + pub(crate) context: CanvasRenderingContext2d, + /// The `ImageData` covering the whole surface, reallocated only + /// when the surface size changes. + #[get(pub(crate))] + #[get_mut(pub(crate))] + #[set(pub(crate))] + pub(crate) image: ImageData, + /// The JavaScript `Uint8ClampedArray` the `ImageData` reads, held + /// so a frame is written into the real backing store rather than + /// into a Rust copy of it. + #[get(pub(crate))] + #[get_mut(pub(crate))] + #[set(pub(crate))] + pub(crate) backing: Uint8ClampedArray, + /// The width the current `ImageData` was allocated for. + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] + pub(crate) image_width: u32, + /// The height the current `ImageData` was allocated for. + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] + pub(crate) image_height: u32, +} diff --git a/engine/src/raytracing/backend/data/const.rs b/engine/src/raytracing/backend/data/const.rs new file mode 100644 index 00000000..1b3eebde --- /dev/null +++ b/engine/src/raytracing/backend/data/const.rs @@ -0,0 +1,134 @@ +/// The `getContext` context id for a Canvas 2D rendering context. +pub(crate) const CANVAS_CONTEXT_ID_2D: &str = "2d"; + +/// The `getContext` context id for a WebGL 2 rendering context. +pub(crate) const WEBGL_CONTEXT_ID_2D: &str = "webgl2"; + +/// The `getContext` context id for a WebGPU canvas rendering context. +pub(crate) const WEBGPU_CONTEXT_ID: &str = "webgpu"; + +/// The `GL_TEXTURE_2D` target the packed data textures bind to. +pub(crate) const WEBGL_TEXTURE_TARGET_2D: u32 = 0x0de1; + +/// The first texture unit the packed scene texture binds to. +pub(crate) const WEBGL_TEXTURE_UNIT_SCENE: u32 = 0x84c0; + +/// The second texture unit the packed frame texture binds to. +pub(crate) const WEBGL_TEXTURE_UNIT_FRAME: u32 = 0x84c1; + +/// The base mip level every packed texture allocation and sub-image +/// write targets. +pub(crate) const WEBGL_MIP_LEVEL_ZERO: i32 = 0; + +/// The `RGBA32F` sized internal format the packed data textures use. +pub(crate) const WEBGL_FORMAT_RGBA32F: i32 = 0x8814; + +/// The `RGBA` pixel format matching [`WEBGL_FORMAT_RGBA32F`]. +pub(crate) const WEBGL_PIXEL_FORMAT_RGBA: u32 = 0x1908; + +/// The `FLOAT` pixel type a `RGBA32F` texture uploads through. +pub(crate) const WEBGL_TYPE_FLOAT: u32 = 0x1406; + +/// The `TEXTURE_MIN_FILTER` parameter, set to `NEAREST` so no float +/// filtering extension is ever consulted. +pub(crate) const WEBGL_TEXTURE_MIN_FILTER: u32 = 0x2801; + +/// The `TEXTURE_MAG_FILTER` parameter, set to `NEAREST`. +pub(crate) const WEBGL_TEXTURE_MAG_FILTER: u32 = 0x2800; + +/// The `TEXTURE_WRAP_S` parameter. +pub(crate) const WEBGL_TEXTURE_WRAP_S: u32 = 0x2802; + +/// The `TEXTURE_WRAP_T` parameter. +pub(crate) const WEBGL_TEXTURE_WRAP_T: u32 = 0x2803; + +/// The `CLAMP_TO_EDGE` wrap mode, the only wrap mode that keeps an +/// out-of-range `texelFetch` from reading the opposite edge. +pub(crate) const WEBGL_CLAMP_TO_EDGE: i32 = 0x812f; + +/// The `NEAREST` minification and magnification filter. +pub(crate) const WEBGL_NEAREST: i32 = 0x2600; + +/// The base of the `TRIANGLES` draw every backend issues: the +/// fullscreen triangle is three vertices. +pub(crate) const WEBGL_FIRST_VERTEX: i32 = 0; + +/// The vertex count of the fullscreen triangle. +pub(crate) const WEBGL_VERTEX_COUNT: i32 = 3; + +/// The error reported when a renderer is used after +/// [`RayTraceRenderer::dispose`](super::RayTraceRenderer::dispose). +pub(crate) const ERROR_DISPOSED: &str = "the ray-trace renderer has been disposed"; + +/// The error reported when a zero-sized surface is requested. +pub(crate) const ERROR_ZERO_DIMENSION: &str = "a ray-trace surface must be at least 1x1"; + +/// The error reported when the requested dimensions overflow the +/// addressable byte count of a `usize` allocation. +pub(crate) const ERROR_DIMENSION_OVERFLOW: &str = + "the requested ray-trace dimensions exceed the addressable buffer size"; + +/// The error reported when one requested dimension exceeds the +/// per-axis pixel ceiling the backend enforces. +pub(crate) const ERROR_GPU_LIMIT: &str = + "one requested ray-trace dimension exceeds this backend's per-axis pixel limit"; + +/// The error reported when the scene cannot be packed for the GPU. +pub(crate) const ERROR_SCENE_PACK: &str = "the scene cannot be packed for the GPU"; + +/// The error reported when the frame block cannot be packed. +pub(crate) const ERROR_FRAME_PACK: &str = "the frame block cannot be packed"; + +/// The error reported when the browser exposes no WebGL 2 context. +pub(crate) const ERROR_WEBGL_UNAVAILABLE: &str = + "canvas.get_context('webgl2') did not yield a WebGL 2 context"; + +/// The error reported when a WebGL call throws. +pub(crate) const ERROR_WEBGL_CALL: &str = "a WebGL 2 call threw"; + +/// The error reported when the WebGL context was lost. +pub(crate) const ERROR_WEBGL_CONTEXT_LOST: &str = "the WebGL 2 context is lost"; + +/// The error reported when the packed texture could not be created. +pub(crate) const ERROR_WEBGL_TEXTURE: &str = "the WebGL 2 packed texture could not be created"; + +/// The error reported when the ray-trace program could not be created. +pub(crate) const ERROR_WEBGL_PROGRAM: &str = "the ray-trace WebGL 2 program could not be created"; + +/// The error reported when the browser exposes no WebGPU. +pub(crate) const ERROR_WEBGPU_UNAVAILABLE: &str = "navigator.gpu is unavailable"; + +/// The error reported when the WebGPU adapter request failed. +pub(crate) const ERROR_WEBGPU_ADAPTER: &str = "no WebGPU adapter is available"; + +/// The error reported when the WebGPU device request failed. +pub(crate) const ERROR_WEBGPU_DEVICE: &str = "no WebGPU device is available"; + +/// The error reported when the WebGPU canvas context is missing. +pub(crate) const ERROR_WEBGPU_CONTEXT: &str = + "canvas.get_context('webgpu') did not yield a WebGPU context"; + +/// The error reported when a WebGPU resource creation threw. +pub(crate) const ERROR_WEBGPU_CALL: &str = "a WebGPU call threw"; + +/// The error reported when the WebGPU device was lost. +pub(crate) const ERROR_WEBGPU_DEVICE_LOST: &str = "the WebGPU device was lost"; + +/// The error reported when the browser exposes no Canvas 2D context. +pub(crate) const ERROR_CANVAS_UNAVAILABLE: &str = + "canvas.get_context('2d') did not yield a 2D context"; + +/// The error reported when the `ImageData` covering the surface could +/// not be allocated. +pub(crate) const ERROR_CANVAS_IMAGE_DATA: &str = + "the ImageData covering the ray-trace surface could not be allocated"; + +/// The error reported when the canvas blit threw. +pub(crate) const ERROR_CANVAS_PUT: &str = "the canvas ImageData blit threw"; + +/// The error reported when an adapter resolves to no device. +pub(crate) const ERROR_WEBGPU_NO_ADAPTER: &str = "the WebGPU adapter request resolved to null"; + +/// The number of RGBA byte channels one surface pixel occupies, which +/// is what turns a pixel count into the output buffer's length. +pub(crate) const SURFACE_RGBA_CHANNELS: usize = 4; diff --git a/engine/src/raytracing/backend/data/enum.rs b/engine/src/raytracing/backend/data/enum.rs new file mode 100644 index 00000000..163fdda4 --- /dev/null +++ b/engine/src/raytracing/backend/data/enum.rs @@ -0,0 +1,22 @@ +use super::*; + +/// The rendering backend a [`RayTraceRenderer`] drives. +/// +/// The backend is chosen by the caller at construction and is never +/// substituted afterwards: a renderer that was asked for WebGPU reports +/// an error when the GPU path fails instead of quietly drawing through +/// Canvas 2D, because a silent substitution turns a diagnosable +/// validation error into a silently slower frame with different pixels. +#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)] +pub enum RayTraceBackend { + /// The CPU reference path: the frame is traced on the CPU and blitted + /// through an `ImageData` upload. + #[default] + Canvas2D, + /// The WebGL 2 path: a fullscreen triangle samples two RGBA32F data + /// textures and writes the default framebuffer. + WebGl2, + /// The WebGPU path: a fullscreen triangle reads two storage buffers + /// through a bind group and writes the swap-chain texture. + WebGpu, +} diff --git a/engine/src/raytracing/backend/data/fn.rs b/engine/src/raytracing/backend/data/fn.rs new file mode 100644 index 00000000..707e4ce7 --- /dev/null +++ b/engine/src/raytracing/backend/data/fn.rs @@ -0,0 +1,127 @@ +use super::*; + +/// Describes a thrown [`JsValue`] without ever losing it. +/// +/// # Arguments +/// +/// - `&JsValue` - The thrown value. +/// +/// # Returns +/// +/// - `String` - The value rendered as a string, or a debug rendering +/// when the value is not convertible. +pub(crate) fn describe(reason: &JsValue) -> String { + match reason.as_string() { + Some(text) if !text.is_empty() => text, + _ => format!("{reason:?}"), + } +} + +/// Builds a [`JsValue`] error from a stable code and a detail string. +/// +/// Every backend failure surfaces through this one constructor, so a +/// caller can branch on the code while a log keeps the driver's own +/// wording. A failure never degrades to `JsValue::UNDEFINED`, which +/// would make a dropped frame indistinguishable from a successful one. +/// +/// # Arguments +/// +/// - `&str` - The stable, machine-readable error code. +/// - `String` - The detail appended after the code. +/// +/// # Returns +/// +/// - `JsValue` - The error value. +pub(crate) fn backend_error(code: &str, detail: String) -> JsValue { + JsValue::from_str(format!("[{code}] {detail}").as_str()) +} + +/// Validates a requested surface size and reports the RGBA bytes it +/// needs. +/// +/// Two distinct budgets are checked, and neither is a substitute for +/// the other. The byte count is computed with checked arithmetic so a +/// request that overflows the address space is rejected instead of +/// wrapping into a short allocation; the optional limit is a +/// per-axis pixel ceiling the driver enforces on one texture or viewport +/// dimension, so it is compared against each dimension rather than +/// against the byte total. The byte budget of a storage binding is not +/// consulted here at all: a surface is not a packed buffer, and +/// rejecting a perfectly renderable size because it would exceed a +/// binding limit is the wrong failure. +/// +/// The dimension is never silently clamped: a caller that asks for a +/// surface the hardware cannot address gets an error and keeps the +/// previous, still valid, size. +/// +/// # Arguments +/// +/// - `u32` - The requested width in pixels. +/// - `u32` - The requested height in pixels. +/// - `Option` - The largest pixel count allowed on one axis, when +/// the backend exposes one. +/// +/// # Returns +/// +/// - `Result` - The RGBA byte length of the surface, or +/// the reason the size is not renderable. +pub(crate) fn validate_surface( + width: u32, + height: u32, + limit: Option, +) -> Result { + if width == 0 || height == 0 { + return Err(backend_error( + ERROR_ZERO_DIMENSION, + format!("requested {width}x{height}"), + )); + } + let pixels: usize = match (width as usize).checked_mul(height as usize) { + Some(product) => product, + None => { + return Err(backend_error( + ERROR_DIMENSION_OVERFLOW, + format!("{width}x{height} overflows the addressable pixel count"), + )); + } + }; + let bytes: usize = match pixels.checked_mul(SURFACE_RGBA_CHANNELS) { + Some(product) => product, + None => { + return Err(backend_error( + ERROR_DIMENSION_OVERFLOW, + format!("{width}x{height} overflows the addressable byte count"), + )); + } + }; + if let Some(maximum) = limit + && (width > maximum || height > maximum) + { + return Err(backend_error( + ERROR_GPU_LIMIT, + format!("{width}x{height} exceeds the {maximum} pixel limit on one axis"), + )); + } + Ok(bytes) +} + +/// Resizes a reused output buffer to exactly one surface of RGBA bytes. +/// +/// The buffer is only reallocated when it has to grow, so a repeated +/// render at a fixed size reuses the same allocation. +/// +/// # Arguments +/// +/// - `&mut Vec` - The reused buffer. +/// - `usize` - The RGBA byte length of one surface. +/// +/// # Returns +/// +/// - `()` - The resize itself. +pub(crate) fn resize_pixel_buffer(pixels: &mut Vec, length: usize) { + if pixels.len() < length { + pixels.resize(length, 0); + } else { + pixels.truncate(length); + } +} diff --git a/engine/src/raytracing/backend/data/impl.rs b/engine/src/raytracing/backend/data/impl.rs new file mode 100644 index 00000000..9afb76d8 --- /dev/null +++ b/engine/src/raytracing/backend/data/impl.rs @@ -0,0 +1,310 @@ +use super::*; + +/// Implements construction, scene updates, rendering, resizing, and +/// teardown for [`RayTraceRenderer`]. +/// +/// Only [`RayTraceRenderer::init`] is asynchronous, because it is the +/// only step that awaits a browser promise. Every other call is +/// synchronous, so a render loop drives the renderer without awaiting. +/// +/// Every step that can fail validates everything it can before it +/// mutates anything, and commits its own state change only after the +/// step that can fail has succeeded. A rejected call therefore leaves +/// the renderer exactly as it was, on the canvas as well as in memory. +impl RayTraceRenderer { + /// Creates a renderer bound to one explicitly requested backend. + /// + /// The requested surface dimensions and the initial scene are both + /// validated before any GPU resource is created, so an illegal + /// input is rejected before a device, a pipeline, or two storage + /// buffers are allocated and then thrown away. + /// + /// The backend is never substituted. If the requested backend is + /// unavailable, if a shader fails to compile, or if the WebGPU + /// pipeline fails validation, this returns the driver's own + /// diagnostic — awaiting the WebGPU compilation info and error + /// scopes, because WebGPU reports both asynchronously and a + /// `catchJs` wrapper around the synchronous calls would report + /// success for an invalid pipeline. + /// + /// # Arguments + /// + /// - `HtmlCanvasElement` - The canvas to draw into. + /// - `RayTraceBackend` - The backend to drive, with no fallback. + /// - `&RayTraceScene` - The initial scene, packed and uploaded here. + /// + /// # Returns + /// + /// - `Result` - The ready renderer, or the reason it + /// could not be created. + pub async fn init( + canvas: HtmlCanvasElement, + backend: RayTraceBackend, + scene: &RayTraceScene, + ) -> Result { + let width: u32 = canvas.width(); + let height: u32 = canvas.height(); + validate_surface(width, height, None)?; + if let Err(reason) = scene.encode_gpu() { + return Err(backend_error(ERROR_SCENE_PACK, reason)); + } + let mut renderer: RayTraceRenderer = RayTraceRenderer { + canvas, + backend, + adapter: None, + scene: RayTraceScene::new(Vec::new()), + packed_frame: Vec::new(), + pixels: Vec::new(), + width, + height, + disposed: false, + }; + // The adapter owns the GPU resources, so a failure after this + // point has to run its release. `adapter` is filled in before + // the fallible steps below precisely so that the early return + // releases it instead of leaking it. + renderer.set_adapter(Some( + RayTraceBackendAdapter::attach(renderer.get_canvas(), backend, width, height).await?, + )); + if let Err(reason) = renderer.resize(width, height) { + renderer.release_backend(); + return Err(reason); + } + if let Err(reason) = renderer.update_scene(scene) { + renderer.release_backend(); + return Err(reason); + } + Ok(renderer) + } + + /// Replaces the scene, re-packing and re-uploading it exactly once. + /// + /// The update is transactional. The new scene is packed into a + /// buffer staged outside the renderer, then uploaded; only once the + /// upload has succeeded is the renderer's own scene mirror swapped + /// in. A failed upload therefore leaves the previous scene both in + /// the renderer and, because the adapter only rebinds a texture + /// after the upload is accepted, still bound on the GPU. The + /// staged block is the only allocation of the call. + /// + /// # Arguments + /// + /// - `&RayTraceScene` - The new scene. + /// + /// # Returns + /// + /// - `Result<(), JsValue>` - `Ok` once the new scene is live. + pub fn update_scene(&mut self, scene: &RayTraceScene) -> Result<(), JsValue> { + if self.get_disposed() { + return Err(backend_error(ERROR_DISPOSED, ERROR_DISPOSED.to_string())); + } + let staged: Vec = match scene.encode_gpu() { + Ok(packed) => packed, + Err(reason) => return Err(backend_error(ERROR_SCENE_PACK, reason)), + }; + let outcome: Result<(), JsValue> = match self.get_mut_adapter().as_mut() { + Some(adapter) => adapter.upload_scene(&staged), + None => Err(backend_error(ERROR_DISPOSED, ERROR_DISPOSED.to_string())), + }; + outcome?; + self.set_scene(scene.clone()); + Ok(()) + } + + /// Traces one frame with the current scene at the given camera. + /// + /// A steady-state frame reuses every buffer it already owns. The + /// Canvas path moves the pixel buffer out, traces into it, and moves + /// it back whether the trace succeeded or failed, so a rejected + /// frame still leaves the buffer allocated for the next attempt. + /// The GPU paths refill the packed frame through its existing + /// capacity and issue one draw. + /// + /// # Arguments + /// + /// - `&Camera3D` - The camera supplying the eye, target, up, and + /// field of view. + /// - `&LightingUniforms` - The lights, ambient, and shading eye. + /// - `&RayTraceSettings` - The sampling grid and bounce limit. + /// + /// # Returns + /// + /// - `Result<(), JsValue>` - `Ok` once the frame is submitted. + pub fn render( + &mut self, + camera: &Camera3D, + lights: &LightingUniforms, + settings: &RayTraceSettings, + ) -> Result<(), JsValue> { + if self.get_disposed() { + return Err(backend_error(ERROR_DISPOSED, ERROR_DISPOSED.to_string())); + } + match self.get_backend() { + RayTraceBackend::Canvas2D => self.render_canvas(camera, lights, settings), + RayTraceBackend::WebGl2 | RayTraceBackend::WebGpu => { + self.render_gpu(camera, lights, settings) + } + } + } + + /// Traces the CPU frame and blits it through the reused image. + /// + /// # Arguments + /// + /// - `&Camera3D` - The camera supplying the eye, target, up, and + /// field of view. + /// - `&LightingUniforms` - The lights, ambient, and shading eye. + /// - `&RayTraceSettings` - The sampling grid and bounce limit. + /// + /// # Returns + /// + /// - `Result<(), JsValue>` - `Ok` once the frame is blitted. + fn render_canvas( + &mut self, + camera: &Camera3D, + lights: &LightingUniforms, + settings: &RayTraceSettings, + ) -> Result<(), JsValue> { + let mut pixels: Vec = mem::take(self.get_mut_pixels()); + let traced: Result<(), String> = render_raytrace_rgba( + self.get_scene(), + camera, + lights, + settings, + self.get_width(), + self.get_height(), + &mut pixels, + ); + let presented: Result<(), JsValue> = match self.get_mut_adapter().as_mut() { + Some(adapter) => match traced { + Ok(()) => adapter.present(&pixels), + Err(reason) => Err(backend_error(ERROR_FRAME_PACK, reason)), + }, + None => Err(backend_error(ERROR_DISPOSED, ERROR_DISPOSED.to_string())), + }; + *self.get_mut_pixels() = pixels; + presented + } + + /// Refills the packed frame and issues the GPU draw. + /// + /// # Arguments + /// + /// - `&Camera3D` - The camera supplying the eye, target, up, and + /// field of view. + /// - `&LightingUniforms` - The lights, ambient, and shading eye. + /// - `&RayTraceSettings` - The sampling grid and bounce limit. + /// + /// # Returns + /// + /// - `Result<(), JsValue>` - `Ok` once the frame is submitted. + fn render_gpu( + &mut self, + camera: &Camera3D, + lights: &LightingUniforms, + settings: &RayTraceSettings, + ) -> Result<(), JsValue> { + let mut frame: Vec = mem::take(self.get_mut_packed_frame()); + let packed: Result<(), String> = pack_raytrace_frame( + &mut frame, + camera, + lights, + settings, + self.get_width(), + self.get_height(), + ); + let drawn: Result<(), JsValue> = match (packed, self.get_mut_adapter().as_mut()) { + (Ok(()), Some(adapter)) => adapter.draw(&frame), + (Ok(()), None) => Err(backend_error(ERROR_DISPOSED, ERROR_DISPOSED.to_string())), + (Err(reason), _) => Err(backend_error(ERROR_FRAME_PACK, reason)), + }; + *self.get_mut_packed_frame() = frame; + drawn + } + + /// Resizes the surface, reallocating only what the new size needs. + /// + /// The call is transactional in both directions. The requested size + /// is validated first, then the adapter applies it, because the + /// adapter is the only step that can fail for a reason the shared + /// layer cannot check: a lost context or a lost device. Only once + /// that step has succeeded are the renderer's own size, the canvas + /// backing store, and the CPU pixel buffer updated — so a rejected + /// request leaves the canvas showing the previous, still valid, + /// surface instead of one the renderer never adopted. The order + /// within the commit does not matter, because nothing past the + /// adapter can fail. + /// + /// A zero, overflowing, or oversized request is rejected before any + /// state is touched. A size the hardware cannot address is an error + /// rather than a silent clamp. + /// + /// # Arguments + /// + /// - `u32` - The new width in pixels. + /// - `u32` - The new height in pixels. + /// + /// # Returns + /// + /// - `Result<(), JsValue>` - `Ok` once the new size is live. + pub fn resize(&mut self, width: u32, height: u32) -> Result<(), JsValue> { + if self.get_disposed() { + return Err(backend_error(ERROR_DISPOSED, ERROR_DISPOSED.to_string())); + } + let byte_length: usize = match self.get_mut_adapter().as_mut() { + Some(adapter) => { + let limit: Option = adapter.dimension_limit(); + let bytes: usize = validate_surface(width, height, limit)?; + adapter.resize(width, height)?; + bytes + } + None => { + validate_surface(width, height, None)?; + return Err(backend_error(ERROR_DISPOSED, ERROR_DISPOSED.to_string())); + } + }; + self.set_width(width); + self.set_height(height); + self.get_mut_canvas().set_width(width); + self.get_mut_canvas().set_height(height); + resize_pixel_buffer(self.get_mut_pixels(), byte_length); + Ok(()) + } + + /// Releases every resource the renderer owns, idempotently. + /// + /// Calling this twice is safe: the second call finds the disposed + /// flag already set and releases nothing. Cleanup also runs when the + /// renderer is dropped, and the flag is set before the release so a + /// renderer dropped right after an explicit dispose destroys nothing + /// twice. + pub fn dispose(&mut self) { + if self.get_disposed() { + return; + } + self.set_disposed(true); + self.release_backend(); + } + + /// Destroys the adapter's resources at most once. + pub(crate) fn release_backend(&mut self) { + if let Some(mut adapter) = mem::take(self.get_mut_adapter()) { + adapter.release(); + } + } +} + +impl Drop for RayTraceRenderer { + /// Releases the backend resources the renderer owns. + /// + /// Cleanup is idempotent: a renderer that was explicitly disposed + /// releases nothing here, and one dropped without being disposed + /// releases exactly what it still owns, once. + fn drop(&mut self) { + if self.get_disposed() { + return; + } + self.set_disposed(true); + self.release_backend(); + } +} diff --git a/engine/src/raytracing/backend/data/mod.rs b/engine/src/raytracing/backend/data/mod.rs new file mode 100644 index 00000000..427f588b --- /dev/null +++ b/engine/src/raytracing/backend/data/mod.rs @@ -0,0 +1,11 @@ +mod r#const; +mod r#enum; +mod r#fn; +mod r#impl; +mod r#struct; + +pub use {r#enum::*, r#struct::*}; + +pub(crate) use {r#const::*, r#fn::*}; + +use super::*; diff --git a/engine/src/raytracing/backend/data/struct.rs b/engine/src/raytracing/backend/data/struct.rs new file mode 100644 index 00000000..a1c23d0a --- /dev/null +++ b/engine/src/raytracing/backend/data/struct.rs @@ -0,0 +1,89 @@ +use super::*; + +/// A reusable ray-trace surface driven by one fixed backend. +/// +/// The renderer owns every resource its backend needs: the packed CPU +/// buffers, the GPU textures, buffers, program, pipeline, and bind +/// groups, and the output pixel buffer. They are created once — during +/// [`RayTraceRenderer::init`](super::RayTraceRenderer::init), a resize, +/// or a scene update — and reused by every later +/// [`render`](super::RayTraceRenderer::render). A steady-state frame +/// allocates nothing and repacks nothing. +/// +/// The backend is explicit. There is no capability probe and no +/// fallback: a WebGPU request on a browser without WebGPU returns an +/// error instead of quietly drawing a Canvas 2D frame. +/// +/// Every method returns [`JsValue`]. An `Ok` always means the frame was +/// submitted; a failure never degrades to `JsValue::UNDEFINED`, which +/// would make a dropped frame indistinguishable from a good one. +/// +/// Every field is `pub(crate)` but is only ever touched through the +/// accessors the `Data` derive generates, so no consumer can hold a +/// second reference to an owned resource or write a size the renderer's +/// own accounting does not know about. +#[derive(Debug, Data)] +pub struct RayTraceRenderer { + /// The canvas element every backend draws into. + #[get(pub(crate))] + #[get_mut(pub(crate))] + #[set(pub(crate))] + pub(crate) canvas: HtmlCanvasElement, + /// The backend this renderer was constructed for, immutable for the + /// renderer's whole lifetime. + #[get(pub(crate), type(copy))] + #[get_mut(skip)] + #[set(skip)] + pub(crate) backend: RayTraceBackend, + /// The active backend adapter, or `None` once released. + /// + /// There is deliberately no plain getter: the adapter owns every GL + /// and GPU object the renderer holds and is not `Clone`, so handing + /// out a copy is impossible and a `get_*` accessor on an `Option` + /// field would unwrap. Reads borrow through the mutable accessor, + /// which yields the only reference to the live adapter. + #[get(skip)] + #[get_mut(pub(crate))] + #[set(pub(crate))] + pub(crate) adapter: Option, + /// The CPU mirror of the scene, so a failed GPU upload keeps the + /// previous, still renderable, scene. The packed block itself is not + /// mirrored: the GPU copy is the live one and a second copy would be + /// allocated on every update and read by nobody. + #[get(pub(crate))] + #[get_mut(skip)] + #[set(pub(crate))] + pub(crate) scene: RayTraceScene, + /// The packed frame block, refilled every frame through its reused + /// capacity. + #[get(skip)] + #[get_mut(pub(crate))] + #[set(pub(crate))] + pub(crate) packed_frame: Vec, + /// The reused RGBA output buffer of the Canvas path. It stays empty + /// on the GPU paths, which present through the driver instead of + /// through a CPU copy of the frame. + #[get(skip)] + #[get_mut(pub(crate))] + #[set(pub(crate))] + pub(crate) pixels: Vec, + /// The surface width in pixels. + #[get(pub(crate), type(copy))] + #[get_mut(skip)] + #[set(pub(crate))] + pub(crate) width: u32, + /// The surface height in pixels. + #[get(pub(crate), type(copy))] + #[get_mut(skip)] + #[set(pub(crate))] + pub(crate) height: u32, + /// Whether [`dispose`](super::RayTraceRenderer::dispose) already ran. + /// + /// This is set before any resource is released, which is what makes + /// `dispose` idempotent and stops the drop cleanup from destroying + /// the same GL or GPU object twice. + #[get(pub(crate), type(copy))] + #[get_mut(skip)] + #[set(pub(crate))] + pub(crate) disposed: bool, +} diff --git a/engine/src/raytracing/backend/mod.rs b/engine/src/raytracing/backend/mod.rs new file mode 100644 index 00000000..c26eeaa7 --- /dev/null +++ b/engine/src/raytracing/backend/mod.rs @@ -0,0 +1,11 @@ +mod r#canvas; +mod r#data; +mod r#state; +mod r#webgl; +mod r#webgpu; + +pub use r#data::*; + +pub(crate) use {r#canvas::*, r#state::*, r#webgl::*, r#webgpu::*}; + +use super::*; diff --git a/engine/src/raytracing/backend/state/enum.rs b/engine/src/raytracing/backend/state/enum.rs new file mode 100644 index 00000000..22f38b07 --- /dev/null +++ b/engine/src/raytracing/backend/state/enum.rs @@ -0,0 +1,17 @@ +use super::*; + +/// The active backend behind a [`RayTraceRenderer`]. +/// +/// The enum holds exactly one live adapter, so there is never a second +/// surface, program, or device alive at once and no resource is shared +/// between two owners: [`RayTraceBackendAdapter::release`] destroys only +/// what this variant owns, never a caller's device or context. +#[derive(Debug)] +pub(crate) enum RayTraceBackendAdapter { + /// The CPU reference path. + Canvas2D(CanvasRayTraceBackend), + /// The WebGL 2 path. + WebGl2(WebGlRayTraceBackend), + /// The WebGPU path. + WebGpu(WebGpuRayTraceBackend), +} diff --git a/engine/src/raytracing/backend/state/impl.rs b/engine/src/raytracing/backend/state/impl.rs new file mode 100644 index 00000000..ad10996c --- /dev/null +++ b/engine/src/raytracing/backend/state/impl.rs @@ -0,0 +1,138 @@ +use super::*; + +impl RayTraceBackendAdapter { + /// Attaches one explicitly requested backend. + /// + /// A backend that cannot be attached is an error; nothing is left + /// half-built and no other backend is substituted. + /// + /// # Arguments + /// + /// - `&HtmlCanvasElement` - The canvas to draw into. + /// - `RayTraceBackend` - The backend to attach. + /// - `u32` - The surface width in pixels. + /// - `u32` - The surface height in pixels. + /// + /// # Returns + /// + /// - `Result` - The live adapter, or the reason it + /// could not be built. + pub(crate) async fn attach( + canvas: &HtmlCanvasElement, + backend: RayTraceBackend, + width: u32, + height: u32, + ) -> Result { + match backend { + RayTraceBackend::Canvas2D => Ok(Self::Canvas2D(CanvasRayTraceBackend::attach( + canvas, width, height, + )?)), + RayTraceBackend::WebGl2 => Ok(Self::WebGl2(WebGlRayTraceBackend::attach( + canvas, width, height, + )?)), + RayTraceBackend::WebGpu => { + Ok(Self::WebGpu(WebGpuRayTraceBackend::attach(canvas).await?)) + } + } + } + + /// The largest pixel count this adapter accepts on one axis. + /// + /// The limit is per axis, never a byte total: it is what the driver + /// enforces on one texture or viewport dimension, and comparing a + /// surface's whole RGBA byte count against it would reject sizes the + /// hardware renders perfectly well. + /// + /// # Returns + /// + /// - `Option` - The per-axis pixel ceiling, or `None` for the + /// backends that expose none to enforce. + pub(crate) fn dimension_limit(&self) -> Option { + match self { + Self::Canvas2D(backend) => backend.dimension_limit(), + Self::WebGl2(backend) => backend.dimension_limit(), + Self::WebGpu(backend) => backend.dimension_limit(), + } + } + + /// Applies a new surface size to the attached adapter. + /// + /// # Arguments + /// + /// - `u32` - The surface width in pixels. + /// - `u32` - The surface height in pixels. + /// + /// # Returns + /// + /// - `Result<(), JsValue>` - `Ok` once the new size is live. + pub(crate) fn resize(&mut self, width: u32, height: u32) -> Result<(), JsValue> { + match self { + Self::Canvas2D(backend) => backend.resize(width, height), + Self::WebGl2(backend) => backend.resize(width, height), + Self::WebGpu(backend) => backend.resize(width, height), + } + } + + /// Uploads the packed scene block. + /// + /// # Arguments + /// + /// - `&[f32]` - The packed scene block. + /// + /// # Returns + /// + /// - `Result<(), JsValue>` - `Ok` once the upload was issued, or + /// `Ok` for the Canvas path which traces on the CPU. + pub(crate) fn upload_scene(&mut self, packed: &[f32]) -> Result<(), JsValue> { + match self { + Self::Canvas2D(_) => Ok(()), + Self::WebGl2(backend) => backend.upload_scene(packed), + Self::WebGpu(backend) => backend.upload_scene(packed), + } + } + + /// Issues the frame on the GPU paths. + /// + /// # Arguments + /// + /// - `&[f32]` - The packed frame block. + /// + /// # Returns + /// + /// - `Result<(), JsValue>` - `Ok` once the frame is submitted, or + /// `Ok` for the Canvas path, which presents through + /// [`RayTraceBackendAdapter::present`]. + pub(crate) fn draw(&mut self, frame: &[f32]) -> Result<(), JsValue> { + match self { + Self::Canvas2D(_) => Ok(()), + Self::WebGl2(backend) => backend.draw(frame), + Self::WebGpu(backend) => backend.draw(frame), + } + } + + /// Presents the already-traced Canvas pixels. + /// + /// # Arguments + /// + /// - `&[u8]` - The traced RGBA bytes. + /// + /// # Returns + /// + /// - `Result<(), JsValue>` - `Ok` once the blit was accepted, or + /// `Ok` for the GPU paths which present through their own draw. + pub(crate) fn present(&mut self, pixels: &[u8]) -> Result<(), JsValue> { + match self { + Self::Canvas2D(backend) => backend.present(pixels), + _ => Ok(()), + } + } + + /// Releases every resource this adapter owns. + pub(crate) fn release(&mut self) { + match self { + Self::Canvas2D(backend) => backend.release(), + Self::WebGl2(backend) => backend.release(), + Self::WebGpu(backend) => backend.release(), + } + } +} diff --git a/engine/src/raytracing/backend/state/mod.rs b/engine/src/raytracing/backend/state/mod.rs new file mode 100644 index 00000000..c274d244 --- /dev/null +++ b/engine/src/raytracing/backend/state/mod.rs @@ -0,0 +1,6 @@ +mod r#enum; +mod r#impl; + +pub(crate) use r#enum::*; + +use super::*; diff --git a/engine/src/raytracing/backend/webgl/const.rs b/engine/src/raytracing/backend/webgl/const.rs new file mode 100644 index 00000000..b2475f5e --- /dev/null +++ b/engine/src/raytracing/backend/webgl/const.rs @@ -0,0 +1,106 @@ +/// The GL stage name used in a WebGL compile diagnostic. +pub(crate) const BACKEND_WEBGL_STAGE_VERTEX: &str = "vertex"; + +/// The GL stage name used in a WebGL compile diagnostic. +pub(crate) const BACKEND_WEBGL_STAGE_FRAGMENT: &str = "fragment"; + +/// The sampler uniform name of the packed scene texture. +pub(crate) const RAYTRACE_GLSL_UNIFORM_SCENE: &str = "u_scene"; + +/// The sampler uniform name of the packed frame texture. +pub(crate) const RAYTRACE_GLSL_UNIFORM_FRAME: &str = "u_frame"; + +/// The texture-unit index the scene sampler reads. +pub(crate) const WEBGL_SAMPLER_UNIT_SCENE: i32 = 0; + +/// The texture-unit index the frame sampler reads. +pub(crate) const WEBGL_SAMPLER_UNIT_FRAME: i32 = 1; + +/// The texels one `vec4` slot occupies in a packed data texture. +pub(crate) const WEBGL_PACKED_TEXELS_PER_VEC4: usize = 4; + +/// The number of entries `MAX_VIEWPORT_DIMS` reports. +pub(crate) const WEBGL_VIEWPORT_AXIS_COUNT: usize = 2; + +/// The largest per-axis viewport dimension accepted from a driver, so a +/// context reporting an absurd pair still yields a conservative limit +/// rather than one no surface could ever satisfy. +pub(crate) const WEBGL_VIEWPORT_DIMENSION_CEILING: u32 = 65535; + +/// The most pending `glGetError` codes one drain reads, so a context +/// that reports errors faster than they clear cannot spin forever. +pub(crate) const WEBGL_ERROR_DRAIN_LIMIT: usize = 32; + +/// The edge of the smallest texture a context can be asked for, used as +/// the placeholder allocation so a context that never uploads a block +/// still owns a complete, bindable texture. +pub(crate) const WEBGL_PLACEHOLDER_TEXELS: i32 = 1; + +/// The number of mip levels `texStorage2D` allocates. One level is +/// exactly what a packed data texture needs: it is sampled with +/// `texelFetch` at level zero and never minified, so a mip chain would +/// be pure memory. +pub(crate) const WEBGL_MIP_LEVELS_ONE: i32 = 1; + +/// The `glGetError` code meaning no error is pending. +pub(crate) const WEBGL_ERROR_NO_ERROR: u32 = 0; + +/// The `MAX_TEXTURE_SIZE` assumed when the implementation reports no +/// usable value, the smallest bound every WebGL 2 context guarantees. +pub(crate) const WEBGL_TEXTURE_SIZE_LIMIT_FALLBACK: u32 = 2048; + +/// The per-axis `MAX_VIEWPORT_DIMS` assumed when the implementation +/// reports no usable pair, the bound every WebGL 2 context guarantees. +pub(crate) const WEBGL_VIEWPORT_DIMENSION_FALLBACK: u32 = 1024; + +/// The `glGetError` name for an illegal enum value. +pub(crate) const WEBGL_ERROR_NAME_INVALID_ENUM: &str = "INVALID_ENUM"; + +/// The `glGetError` name for an out-of-range numeric argument. +pub(crate) const WEBGL_ERROR_NAME_INVALID_VALUE: &str = "INVALID_VALUE"; + +/// The `glGetError` name for an operation illegal in the current state. +pub(crate) const WEBGL_ERROR_NAME_INVALID_OPERATION: &str = "INVALID_OPERATION"; + +/// The `glGetError` name for a failed allocation. +pub(crate) const WEBGL_ERROR_NAME_OUT_OF_MEMORY: &str = "OUT_OF_MEMORY"; + +/// The `glGetError` name for a code this backend has no name for. +pub(crate) const WEBGL_ERROR_NAME_UNKNOWN: &str = "an unrecognised code"; + +/// The call name used when a drain reports an error raised before the +/// call being attributed, so the diagnostic does not blame the wrong one. +pub(crate) const WEBGL_ERROR_SOURCE_PENDING: &str = "the pending queue"; + +/// The call name used when a draw's drain reports an error raised by one +/// of the binding calls that preceded it. +pub(crate) const WEBGL_ERROR_SOURCE_BINDING: &str = "the binding state"; + +/// The error detail written when `createVertexArray` produced nothing. +pub(crate) const ERROR_WEBGL_VAO_REJECTED: &str = "createVertexArray returned null"; + +/// The error detail written when `createProgram` produced nothing. +pub(crate) const ERROR_WEBGL_PROGRAM_NULL: &str = "createProgram returned null"; + +/// The error detail written when `createTexture` produced nothing. +pub(crate) const ERROR_WEBGL_TEXTURE_NULL: &str = "createTexture returned null"; + +/// The error detail written when a packed block exceeds what any shape +/// this context can allocate holds. +pub(crate) const ERROR_WEBGL_BLOCK_TOO_LARGE: &str = + "the packed block does not fit a texture this WebGL 2 context can allocate"; + +/// The error reported when the packed texture write was refused. +pub(crate) const ERROR_WEBGL_UPLOAD: &str = "the WebGL 2 packed texture write was refused"; + +/// The error reported when a packed block carries no slots at all. +pub(crate) const ERROR_WEBGL_EMPTY_BLOCK: &str = "the packed block carries no vec4 slots"; + +/// The error reported when a requested surface dimension exceeds what +/// this context accepts. +pub(crate) const ERROR_WEBGL_SURFACE_LIMIT: &str = + "the requested surface dimension exceeds this WebGL 2 context's limit"; + +/// The error reported when the drawing buffer has no drawable area. +pub(crate) const ERROR_WEBGL_EMPTY_DRAWING_BUFFER: &str = + "the WebGL 2 drawing buffer has no drawable area"; diff --git a/engine/src/raytracing/backend/webgl/fn.rs b/engine/src/raytracing/backend/webgl/fn.rs new file mode 100644 index 00000000..db83cf4b --- /dev/null +++ b/engine/src/raytracing/backend/webgl/fn.rs @@ -0,0 +1,798 @@ +use super::*; + +/// Describes a thrown [`JsValue`] without ever losing it. +/// +/// # Arguments +/// +/// - `&JsValue` - The thrown value. +/// +/// # Returns +/// +/// - `String` - The value rendered as a string, or a debug rendering +/// when the value is not convertible. +pub(crate) fn describe_webgl(reason: &JsValue) -> String { + match reason.as_string() { + Some(text) if !text.is_empty() => text, + _ => format!("{reason:?}"), + } +} + +/// The human-readable stage name used in WebGL diagnostics. +/// +/// # Arguments +/// +/// - `bool` - Whether this is the vertex stage. +/// +/// # Returns +/// +/// - `&'static str` - The stage name. +fn stage_name(vertex: bool) -> &'static str { + if vertex { + BACKEND_WEBGL_STAGE_VERTEX + } else { + BACKEND_WEBGL_STAGE_FRAGMENT + } +} + +/// Compiles one GLSL stage and returns the driver's own failure log. +/// +/// # Arguments +/// +/// - `&WebGl2RenderingContext` - The context to compile against. +/// - `bool` - Whether this is the vertex stage. +/// - `&str` - The GLSL source. +/// +/// # Returns +/// +/// - `Result` - The compiled shader, or the +/// compile log. +pub(crate) fn compile_stage( + context: &WebGl2RenderingContext, + vertex: bool, + source: &str, +) -> Result { + let stage: u32 = if vertex { + WebGl2RenderingContext::VERTEX_SHADER + } else { + WebGl2RenderingContext::FRAGMENT_SHADER + }; + let name: &str = stage_name(vertex); + let Some(shader) = context.create_shader(stage) else { + return Err(backend_error( + ERROR_WEBGL_PROGRAM, + format!("createShader returned null for the {name} stage"), + )); + }; + context.shader_source(&shader, source); + context.compile_shader(&shader); + let compiled: bool = context + .get_shader_parameter(&shader, WebGl2RenderingContext::COMPILE_STATUS) + .as_bool() + .unwrap_or(false); + if compiled { + return Ok(shader); + } + let log: String = context.get_shader_info_log(&shader).unwrap_or_default(); + context.delete_shader(Some(&shader)); + Err(backend_error( + ERROR_WEBGL_PROGRAM, + format!("the {name} stage failed to compile: {log}"), + )) +} + +/// Compiles and links the ray-trace program. +/// +/// Both stages are compiled before the link and the link log is returned +/// verbatim on failure, so a shader error is never reported as a +/// successful frame. Every object created on the way — the first shader +/// when the second fails to compile, both when the program is refused, +/// and the program itself when the link fails — is deleted before the +/// error returns, so a failed attach leaves nothing allocated. +/// +/// # Arguments +/// +/// - `&WebGl2RenderingContext` - The context to build the program in. +/// +/// # Returns +/// +/// - `Result` - The linked program, or the +/// compile and link diagnostics. +pub(crate) fn link_raytrace_program( + context: &WebGl2RenderingContext, +) -> Result { + let vertex: WebGlShader = compile_stage(context, true, raytrace_glsl_vertex())?; + let fragment: WebGlShader = match compile_stage(context, false, raytrace_glsl_fragment()) { + Ok(value) => value, + Err(reason) => { + context.delete_shader(Some(&vertex)); + return Err(reason); + } + }; + let Some(program) = context.create_program() else { + context.delete_shader(Some(&vertex)); + context.delete_shader(Some(&fragment)); + return Err(backend_error( + ERROR_WEBGL_PROGRAM, + ERROR_WEBGL_PROGRAM_NULL.to_string(), + )); + }; + context.attach_shader(&program, &vertex); + context.attach_shader(&program, &fragment); + context.link_program(&program); + let linked: bool = context + .get_program_parameter(&program, WebGl2RenderingContext::LINK_STATUS) + .as_bool() + .unwrap_or(false); + context.detach_shader(&program, &vertex); + context.detach_shader(&program, &fragment); + context.delete_shader(Some(&vertex)); + context.delete_shader(Some(&fragment)); + if linked { + return Ok(program); + } + let log: String = context.get_program_info_log(&program).unwrap_or_default(); + context.delete_program(Some(&program)); + Err(backend_error( + ERROR_WEBGL_PROGRAM, + format!("the ray-trace program failed to link: {log}"), + )) +} + +/// Resolves one sampler uniform, failing when the compiler dropped it. +/// +/// # Arguments +/// +/// - `&WebGl2RenderingContext` - The context holding the program. +/// - `&WebGlProgram` - The linked program. +/// - `&str` - The uniform name. +/// +/// # Returns +/// +/// - `Result` - The location, or the +/// reason the uniform is missing. +pub(crate) fn resolve_sampler( + context: &WebGl2RenderingContext, + program: &WebGlProgram, + name: &str, +) -> Result { + match context.get_uniform_location(program, name) { + Some(location) => Ok(location), + None => Err(backend_error( + ERROR_WEBGL_PROGRAM, + format!("the uniform {name} is missing from the linked program"), + )), + } +} + +/// Names one `glGetError` code for a diagnostic. +/// +/// # Arguments +/// +/// - `u32` - The code `getError` reported. +/// +/// # Returns +/// +/// - `&'static str` - The GL name of the code. +fn gl_error_name(code: u32) -> &'static str { + match code { + WebGl2RenderingContext::INVALID_ENUM => WEBGL_ERROR_NAME_INVALID_ENUM, + WebGl2RenderingContext::INVALID_VALUE => WEBGL_ERROR_NAME_INVALID_VALUE, + WebGl2RenderingContext::INVALID_OPERATION => WEBGL_ERROR_NAME_INVALID_OPERATION, + WebGl2RenderingContext::OUT_OF_MEMORY => WEBGL_ERROR_NAME_OUT_OF_MEMORY, + _ => WEBGL_ERROR_NAME_UNKNOWN, + } +} + +/// Reads and clears every pending `glGetError`. +/// +/// WebGL reports a rejected call through the error queue rather than by +/// throwing, so a `Result`-only check reports success for a call the +/// driver refused. Draining the queue here is what makes a subsequent +/// diagnostic attribute the error to the call that caused it instead of +/// to some later one. +/// +/// # Arguments +/// +/// - `&WebGl2RenderingContext` - The context to drain. +/// +/// # Returns +/// +/// - `String` - The distinct error names found, empty when the queue was +/// already clear. +fn drain_gl_error(context: &WebGl2RenderingContext) -> String { + let mut names: Vec<&'static str> = Vec::new(); + while names.len() < WEBGL_ERROR_DRAIN_LIMIT { + let code: u32 = context.get_error(); + if code == WEBGL_ERROR_NO_ERROR { + break; + } + let name: &'static str = gl_error_name(code); + if !names.contains(&name) { + names.push(name); + } + } + names.join(", ") +} + +/// Fails when the context has a pending GL error. +/// +/// # Arguments +/// +/// - `&WebGl2RenderingContext` - The context to inspect. +/// - `&'static str` - The stable error code the failure is reported +/// under. +/// - `&str` - The call the diagnostic names as the source. +/// +/// # Returns +/// +/// - `Result<(), JsValue>` - `Ok` when the queue is clear. +pub(crate) fn require_clear_gl_error( + context: &WebGl2RenderingContext, + code: &'static str, + call: &str, +) -> Result<(), JsValue> { + let names: String = drain_gl_error(context); + if names.is_empty() { + return Ok(()); + } + Err(backend_error(code, format!("{call} left {names} pending"))) +} + +/// The number of `vec4` slots a packed float block occupies. +/// +/// # Arguments +/// +/// - `usize` - The packed float count. +/// +/// # Returns +/// +/// - `usize` - The slot count, rounded up so a block that does not fill +/// its final `vec4` still writes whole texels. +pub(crate) fn packed_slot_count(floats: usize) -> usize { + floats.div_ceil(WEBGL_PACKED_TEXELS_PER_VEC4) +} + +/// The texel row width a packed block of `floats` floats is laid out in. +/// +/// A square-ish shape is chosen so a block grows in both dimensions +/// together and the shader's `slot / width` addressing keeps a short +/// row. The width is the square root of the slot count rounded up, so +/// the covered area stays within a factor of two of the block itself: a +/// block of `n` slots occupies `ceil(sqrt(n))` texels across by at most +/// `ceil(sqrt(n))` rows, and a block twice as large is the square root +/// of two times as wide and as tall, not twice. +/// +/// The root is rounded up, never down, because a floor root makes the +/// final row overflow the edge. At a floor width of `floor(sqrt(n))` the +/// row count is `ceil(n / width)`, which runs past the limit well +/// before the block itself does: 1599 slots at a limit of 40 take a +/// floor width of 39 and 41 rows, and `texStorage2D` refuses an edge +/// past the limit even though the whole block fits under it. At an +/// up-rounded width the row count never exceeds the width, so every +/// block `validate_block_capacity` accepted is shaped within the limit +/// without either edge being shortened. +/// +/// No edge is clamped to hide a block that does not fit: the capacity +/// check runs first and rejects anything whose edges would not fit a +/// positive `i32`, so every returned shape holds every slot. +/// +/// # Arguments +/// +/// - `usize` - The packed float count. +/// - `u32` - The largest texture edge this context accepts. +/// +/// # Returns +/// +/// - `Result<(i32, i32), JsValue>` - The row width and row count, each +/// at least one texel and holding every slot, or the reason this +/// context cannot lay the block out. +pub(crate) fn packed_shape(floats: usize, limit: u32) -> Result<(i32, i32), JsValue> { + validate_block_capacity(floats, limit)?; + let slots: usize = packed_slot_count(floats).max(1); + let root: usize = slots.isqrt(); + let width: usize = root + usize::from(root.checked_mul(root) != Some(slots)); + let height: usize = slots.div_ceil(width); + Ok((width as i32, height as i32)) +} + +/// Whether a packed block can be laid out at all under a texture limit. +/// +/// A block whose slot count needs an edge past the limit cannot be +/// shaped into a legal texture, so it is rejected rather than truncated: +/// the shader would read past the end of a shortened upload. The limit +/// is held to the largest edge this `i32` pair can carry, so a context +/// reporting an absurd `MAX_TEXTURE_SIZE` is rejected for every block +/// it cannot actually represent instead of yielding a negative edge. +/// +/// # Arguments +/// +/// - `usize` - The packed float count. +/// - `u32` - The largest texture edge this context accepts. +/// +/// # Returns +/// +/// - `Result<(), JsValue>` - `Ok` when some shape holds the block. +pub(crate) fn validate_block_capacity(floats: usize, limit: u32) -> Result<(), JsValue> { + let slots: usize = packed_slot_count(floats).max(1); + let ceiling: usize = (limit as usize).max(1).min(i32::MAX as usize); + if slots > ceiling.saturating_mul(ceiling) { + return Err(backend_error( + ERROR_WEBGL_BLOCK_TOO_LARGE, + format!("a block of {slots} vec4 slots needs a texture above {ceiling}x{ceiling}"), + )); + } + Ok(()) +} + +/// Allocates one `RGBA32F` data texture of an exact size. +/// +/// The texture is only ever sampled through `texelFetch`, so both +/// filters are `NEAREST` and the wrap mode is `CLAMP_TO_EDGE`: no +/// float-filtering extension is required and no sampler can read across +/// the edge of the block. `texStorage2D` gives the texture its immutable +/// size once, so a sub-image write never re-specifies a shape. +/// +/// The pending error queue is drained before the allocation and checked +/// after it, so a refused allocation that only sets `OUT_OF_MEMORY` — +/// which is how a browser reports an over-budget `texStorage2D`, with +/// no throw — is an error rather than a silently unusable texture. +/// +/// # Arguments +/// +/// - `&WebGl2RenderingContext` - The context to allocate in. +/// - `u32` - The texture unit this texture will be bound to. +/// - `i32` - The texture width in texels. +/// - `i32` - The texture height in texels. +/// +/// # Returns +/// +/// - `Result` - The allocated texture. +pub(crate) fn create_packed_texture( + context: &WebGl2RenderingContext, + unit: u32, + width: i32, + height: i32, +) -> Result { + require_clear_gl_error(context, ERROR_WEBGL_TEXTURE, WEBGL_ERROR_SOURCE_PENDING)?; + let Some(texture) = context.create_texture() else { + return Err(backend_error( + ERROR_WEBGL_TEXTURE, + ERROR_WEBGL_TEXTURE_NULL.to_string(), + )); + }; + context.active_texture(unit); + context.bind_texture(WEBGL_TEXTURE_TARGET_2D, Some(&texture)); + context.tex_parameteri( + WEBGL_TEXTURE_TARGET_2D, + WEBGL_TEXTURE_MIN_FILTER, + WEBGL_NEAREST, + ); + context.tex_parameteri( + WEBGL_TEXTURE_TARGET_2D, + WEBGL_TEXTURE_MAG_FILTER, + WEBGL_NEAREST, + ); + context.tex_parameteri( + WEBGL_TEXTURE_TARGET_2D, + WEBGL_TEXTURE_WRAP_S, + WEBGL_CLAMP_TO_EDGE, + ); + context.tex_parameteri( + WEBGL_TEXTURE_TARGET_2D, + WEBGL_TEXTURE_WRAP_T, + WEBGL_CLAMP_TO_EDGE, + ); + context.tex_storage_2d( + WEBGL_TEXTURE_TARGET_2D, + WEBGL_MIP_LEVELS_ONE, + WEBGL_FORMAT_RGBA32F as u32, + width, + height, + ); + let outcome: Result<(), JsValue> = + require_clear_gl_error(context, ERROR_WEBGL_TEXTURE, "texStorage2D"); + match outcome { + Ok(()) => {} + Err(reason) => { + context.delete_texture(Some(&texture)); + return Err(reason); + } + } + Ok(WebGlRayTraceTexture { + texture, + width, + height, + }) +} + +/// Binds one data texture to a unit. +/// +/// # Arguments +/// +/// - `&WebGl2RenderingContext` - The context to bind in. +/// - `u32` - The texture unit. +/// - `&WebGlTexture` - The texture to bind. +/// +/// # Returns +/// +/// - `Result<(), JsValue>` - `Ok` once the texture is bound. +pub(crate) fn bind_data_texture( + context: &WebGl2RenderingContext, + unit: u32, + texture: &WebGlTexture, +) -> Result<(), JsValue> { + context.active_texture(unit); + context.bind_texture(WEBGL_TEXTURE_TARGET_2D, Some(texture)); + require_clear_gl_error(context, ERROR_WEBGL_CALL, "bindTexture") +} + +/// Refills a packed data texture through `texSubImage2D`. +/// +/// The upload length is rounded up to whole texels so a packed block +/// that does not fill its final `vec4` still writes whole texels; the +/// staging buffer is zero-extended and every float beyond the block is +/// reset to zero, so a block that shrinks cannot leave a previous +/// frame's values readable past its end. The block itself is copied +/// into the staging buffer: uploading the caller's slice directly would +/// write only as many bytes as it holds, which leaves the final partial +/// texel unwritten and makes the shader read uninitialised memory. +/// +/// # Arguments +/// +/// - `&WebGl2RenderingContext` - The context to upload through. +/// - `&WebGlRayTraceTexture` - The destination texture. +/// - `u32` - The texture unit the texture is bound to. +/// - `&[f32]` - The packed block. +/// - `&mut Vec` - The scratch buffer the upload is built in, reused +/// across frames. +/// +/// # Returns +/// +/// - `Result<(), JsValue>` - `Ok` once the upload was accepted. +pub(crate) fn upload_packed( + context: &WebGl2RenderingContext, + target: &WebGlRayTraceTexture, + unit: u32, + packed: &[f32], + scratch: &mut Vec, +) -> Result<(), JsValue> { + let slots: usize = packed_slot_count(packed.len()); + if slots == 0 { + return Err(backend_error( + ERROR_WEBGL_EMPTY_BLOCK, + format!("{} floats pack into no vec4 slot", packed.len()), + )); + } + let rows: i32 = slots.div_ceil(target.get_width() as usize) as i32; + let needed: usize = + (rows as usize) * (target.get_width() as usize) * WEBGL_PACKED_TEXELS_PER_VEC4; + if scratch.len() < needed { + scratch.resize(needed, 0.0); + } + scratch[needed..].fill(0.0); + for (index, value) in packed.iter().enumerate() { + scratch[index] = *value; + } + let upload: &[f32] = &scratch[..needed]; + let view: Float32Array = Float32Array::from(upload); + require_clear_gl_error(context, ERROR_WEBGL_UPLOAD, WEBGL_ERROR_SOURCE_PENDING)?; + bind_data_texture(context, unit, target.get_texture())?; + let outcome: Result<(), JsValue> = context + .tex_sub_image_2d_with_i32_and_i32_and_u32_and_type_and_opt_array_buffer_view( + WEBGL_TEXTURE_TARGET_2D, + WEBGL_MIP_LEVEL_ZERO, + 0, + 0, + target.get_width(), + rows, + WEBGL_PIXEL_FORMAT_RGBA, + WEBGL_TYPE_FLOAT, + Some(view.as_ref() as &Object), + ); + match outcome { + Ok(()) => require_clear_gl_error(context, ERROR_WEBGL_UPLOAD, "texSubImage2D"), + Err(reason) => Err(backend_error(ERROR_WEBGL_UPLOAD, describe_webgl(&reason))), + } +} + +/// Grows the scene texture in place when its block no longer fits. +/// +/// A block that already fits the current texture reuses it and touches +/// nothing, so a repeated update at a fixed size issues one +/// `texSubImage2D` and creates no GL object. A block that does not fit +/// is allocated once at its own shape: the new texture is bound and +/// fully written before it is adopted, so a failed allocation or a +/// failed write leaves the previous, still valid, texture in place. The +/// texture being replaced is deleted only after the swap succeeds, so a +/// rejected block never leaves the backend without a usable texture. +/// +/// # Arguments +/// +/// - `&WebGl2RenderingContext` - The context to allocate in. +/// - `&mut WebGlRayTraceTexture` - The current texture, replaced only +/// on success. +/// - `&mut Vec` - The scratch buffer the upload is built in. +/// - `&[f32]` - The packed scene block. +/// +/// # Returns +/// +/// - `Result<(), JsValue>` - `Ok` once the block is live on the GPU. +pub(crate) fn ensure_scene_texture( + context: &WebGl2RenderingContext, + current: &mut WebGlRayTraceTexture, + scratch: &mut Vec, + packed: &[f32], +) -> Result<(), JsValue> { + grow_texture(context, current, WEBGL_TEXTURE_UNIT_SCENE, scratch, packed) +} + +/// Grows the frame texture in place when its block no longer fits. +/// +/// The mirror of [`ensure_scene_texture`] for the block every frame +/// uploads, so a steady frame reuses one allocation and a larger block +/// reallocates exactly once. +/// +/// # Arguments +/// +/// - `&WebGl2RenderingContext` - The context to allocate in. +/// - `&mut WebGlRayTraceTexture` - The current texture, replaced only +/// on success. +/// - `&mut Vec` - The scratch buffer the upload is built in. +/// - `&[f32]` - The packed frame block. +/// +/// # Returns +/// +/// - `Result<(), JsValue>` - `Ok` once the block is live on the GPU. +pub(crate) fn ensure_frame_texture( + context: &WebGl2RenderingContext, + current: &mut WebGlRayTraceTexture, + scratch: &mut Vec, + packed: &[f32], +) -> Result<(), JsValue> { + grow_texture(context, current, WEBGL_TEXTURE_UNIT_FRAME, scratch, packed) +} + +/// Reallocates and re-uploads one data texture when its block outgrows +/// it, mutating it in place only after the new texture is live. +/// +/// # Arguments +/// +/// - `&WebGl2RenderingContext` - The context to allocate in. +/// - `&mut WebGlRayTraceTexture` - The texture to grow in place. +/// - `u32` - The texture unit the texture is bound to. +/// - `&mut Vec` - The scratch buffer the upload is built in. +/// - `&[f32]` - The packed block. +/// +/// # Returns +/// +/// - `Result<(), JsValue>` - `Ok` once the block is live on the GPU. +fn grow_texture( + context: &WebGl2RenderingContext, + current: &mut WebGlRayTraceTexture, + unit: u32, + scratch: &mut Vec, + packed: &[f32], +) -> Result<(), JsValue> { + let slots: usize = packed_slot_count(packed.len()); + if slots == 0 { + return Err(backend_error( + ERROR_WEBGL_EMPTY_BLOCK, + format!("{} floats pack into no vec4 slot", packed.len()), + )); + } + let held: usize = (current.get_width() as usize) * (current.get_height() as usize); + if slots > held { + let limit: u32 = texture_size_limit(context); + let (width, height): (i32, i32) = packed_shape(packed.len(), limit)?; + let grown: WebGlRayTraceTexture = create_packed_texture(context, unit, width, height)?; + match upload_packed(context, &grown, unit, packed, scratch) { + Ok(()) => {} + Err(reason) => { + context.delete_texture(Some(grown.get_texture())); + return Err(reason); + } + } + context.delete_texture(Some(current.get_texture())); + current.set_texture(grown.get_texture().clone()); + current.set_width(grown.get_width()); + current.set_height(grown.get_height()); + return Ok(()); + } + upload_packed(context, current, unit, packed, scratch) +} + +/// Reads the `MAX_TEXTURE_SIZE` of a context. +/// +/// The query goes through `getParameter` rather than the constant on the +/// binding, because the constant is the interface's minimum while the +/// value the driver actually enforces is what an allocation is checked +/// against. +/// +/// # Arguments +/// +/// - `&WebGl2RenderingContext` - The context to query. +/// +/// # Returns +/// +/// - `u32` - The reported edge, or the guaranteed minimum when the +/// query returns nothing usable. +pub(crate) fn texture_size_limit(context: &WebGl2RenderingContext) -> u32 { + let queried: Result = + context.get_parameter(WebGl2RenderingContext::MAX_TEXTURE_SIZE); + match queried { + Ok(value) => match value.as_f64() { + Some(number) if number >= 1.0 && number.is_finite() => number as u32, + _ => WEBGL_TEXTURE_SIZE_LIMIT_FALLBACK, + }, + Err(_) => WEBGL_TEXTURE_SIZE_LIMIT_FALLBACK, + } +} + +/// Reads the per-axis `MAX_VIEWPORT_DIMS` of a context. +/// +/// # Arguments +/// +/// - `&WebGl2RenderingContext` - The context to query. +/// +/// # Returns +/// +/// - `u32` - The smaller reported axis, or the guaranteed bound when +/// the query returns nothing usable. +pub(crate) fn viewport_dimension_limit(context: &WebGl2RenderingContext) -> u32 { + let queried: Result = + context.get_parameter(WebGl2RenderingContext::MAX_VIEWPORT_DIMS); + match queried { + Ok(value) => match Int32Array::new(&value).to_vec() { + axes if axes.len() == WEBGL_VIEWPORT_AXIS_COUNT => { + let first: i32 = axes[0]; + let second: i32 = axes[1]; + if first <= 0 || second <= 0 { + WEBGL_VIEWPORT_DIMENSION_FALLBACK + } else { + (first.min(second) as u32).min(WEBGL_VIEWPORT_DIMENSION_CEILING) + } + } + _ => WEBGL_VIEWPORT_DIMENSION_FALLBACK, + }, + Err(_) => WEBGL_VIEWPORT_DIMENSION_FALLBACK, + } +} + +/// Rejects a surface whose dimensions exceed this context's limits. +/// +/// The check is per axis and never clamps: a caller that asks for a +/// surface the driver would refuse gets an error and keeps the previous, +/// still valid, size. +/// +/// # Arguments +/// +/// - `&WebGl2RenderingContext` - The context to inspect for a lost +/// state. +/// - `u32` - The requested width in pixels. +/// - `u32` - The requested height in pixels. +/// - `u32` - The largest surface dimension this context accepts. +/// +/// # Returns +/// +/// - `Result<(), JsValue>` - `Ok` when the size is renderable. +pub(crate) fn validate_webgl_surface( + context: &WebGl2RenderingContext, + width: u32, + height: u32, + limit: u32, +) -> Result<(), JsValue> { + if context.is_context_lost() { + return Err(backend_error( + ERROR_WEBGL_CONTEXT_LOST, + ERROR_WEBGL_CONTEXT_LOST.to_string(), + )); + } + if width > limit || height > limit { + return Err(backend_error( + ERROR_WEBGL_SURFACE_LIMIT, + format!("requested {width}x{height}, over the {limit} pixel per-axis limit"), + )); + } + Ok(()) +} + +/// Applies a full-surface viewport derived from the drawing buffer. +/// +/// The canvas width and height may be reset by the browser — setting +/// either one resets the viewport to the new default — so the viewport +/// is re-applied from `drawingBufferWidth` and `drawingBufferHeight` +/// before every draw rather than once per resize. That is also what +/// keeps a device-pixel-ratio change from drawing into a corner. +/// +/// # Arguments +/// +/// - `&WebGl2RenderingContext` - The context to set the viewport in. +/// +/// # Returns +/// +/// - `Result<(), JsValue>` - `Ok` once the viewport covers the whole +/// drawing buffer. +pub(crate) fn apply_full_viewport(context: &WebGl2RenderingContext) -> Result<(), JsValue> { + let width: i32 = context.drawing_buffer_width(); + let height: i32 = context.drawing_buffer_height(); + if width <= 0 || height <= 0 { + return Err(backend_error( + ERROR_WEBGL_EMPTY_DRAWING_BUFFER, + format!("the drawing buffer is {width}x{height}"), + )); + } + require_clear_gl_error(context, ERROR_WEBGL_CALL, WEBGL_ERROR_SOURCE_PENDING)?; + context.viewport(0, 0, width, height); + require_clear_gl_error(context, ERROR_WEBGL_CALL, "viewport") +} + +/// Acquires the WebGL 2 context of a canvas and reports why it failed. +/// +/// # Arguments +/// +/// - `&HtmlCanvasElement` - The canvas to draw into. +/// +/// # Returns +/// +/// - `Result` - The context, or the +/// reason the browser refused one. +pub(crate) fn attach_context( + canvas: &HtmlCanvasElement, +) -> Result { + let lookup: Result, JsValue> = canvas.get_context(WEBGL_CONTEXT_ID_2D); + let context_object: Object = match lookup { + Ok(Some(object)) => object, + Ok(None) => { + return Err(backend_error( + ERROR_WEBGL_UNAVAILABLE, + ERROR_WEBGL_UNAVAILABLE.to_string(), + )); + } + Err(reason) => { + return Err(backend_error( + ERROR_WEBGL_UNAVAILABLE, + describe_webgl(&reason), + )); + } + }; + match context_object.dyn_into() { + Ok(context) => Ok(context), + Err(_) => Err(backend_error( + ERROR_WEBGL_UNAVAILABLE, + ERROR_WEBGL_UNAVAILABLE.to_string(), + )), + } +} + +/// Releases every GL object a half-built attach owns. +/// +/// # Arguments +/// +/// - `&WebGl2RenderingContext` - The context the objects belong to. +/// - `Option<&WebGlProgram>` - The program, when one was linked. +/// - `Option<&WebGlVertexArrayObject>` - The vertex array, when one was +/// created. +/// - `Option<&WebGlRayTraceTexture>` - The scene texture, when one was +/// allocated. +/// - `Option<&WebGlRayTraceTexture>` - The frame texture, when one was +/// allocated. +pub(crate) fn release_partial( + context: &WebGl2RenderingContext, + program: Option<&WebGlProgram>, + vao: Option<&WebGlVertexArrayObject>, + scene_texture: Option<&WebGlRayTraceTexture>, + frame_texture: Option<&WebGlRayTraceTexture>, +) { + if let Some(texture) = frame_texture { + context.delete_texture(Some(texture.get_texture())); + } + if let Some(texture) = scene_texture { + context.delete_texture(Some(texture.get_texture())); + } + if let Some(array) = vao { + context.delete_vertex_array(Some(array)); + } + if let Some(linked) = program { + context.delete_program(Some(linked)); + } +} diff --git a/engine/src/raytracing/backend/webgl/impl.rs b/engine/src/raytracing/backend/webgl/impl.rs new file mode 100644 index 00000000..ac8fffea --- /dev/null +++ b/engine/src/raytracing/backend/webgl/impl.rs @@ -0,0 +1,279 @@ +use super::*; + +impl WebGlRayTraceBackend { + /// Compiles the ray-trace program and binds the fullscreen vertex + /// array. + /// + /// Both shader stages are compiled and linked synchronously and the + /// driver's own info log is returned verbatim on failure, so an + /// invalid program is an error rather than a link that silently + /// produced no executable. + /// + /// The data textures are deliberately not allocated here. Each is + /// created at the shape of the block it will hold, on the first + /// upload that needs it, so an attach reserves two placeholder texels + /// rather than the whole addressable budget of two textures. + /// + /// Every step that can fail cleans up the objects the earlier steps + /// created: a refused vertex array, a failed second texture, or a + /// failed initial resize deletes the program, the vertex array, and + /// whichever texture already existed before returning. + /// + /// # Arguments + /// + /// - `&HtmlCanvasElement` - The canvas to draw into. + /// - `u32` - The surface width in pixels. + /// - `u32` - The surface height in pixels. + /// + /// # Returns + /// + /// - `Result` - The ready backend, or the reason the + /// WebGL 2 path could not be set up. + pub fn attach(canvas: &HtmlCanvasElement, width: u32, height: u32) -> Result { + let context: WebGl2RenderingContext = attach_context(canvas)?; + if context.is_context_lost() { + return Err(backend_error( + ERROR_WEBGL_CONTEXT_LOST, + ERROR_WEBGL_CONTEXT_LOST.to_string(), + )); + } + let program: WebGlProgram = link_raytrace_program(&context)?; + let uniforms: Result<(WebGlUniformLocation, WebGlUniformLocation), JsValue> = + match resolve_sampler(&context, &program, RAYTRACE_GLSL_UNIFORM_SCENE) { + Ok(scene_uniform) => { + match resolve_sampler(&context, &program, RAYTRACE_GLSL_UNIFORM_FRAME) { + Ok(frame_uniform) => Ok((scene_uniform, frame_uniform)), + Err(reason) => Err(reason), + } + } + Err(reason) => Err(reason), + }; + let (scene_uniform, frame_uniform): (WebGlUniformLocation, WebGlUniformLocation) = + match uniforms { + Ok(pair) => pair, + Err(reason) => { + release_partial(&context, Some(&program), None, None, None); + return Err(reason); + } + }; + let Some(vao) = context.create_vertex_array() else { + release_partial(&context, Some(&program), None, None, None); + return Err(backend_error( + ERROR_WEBGL_PROGRAM, + ERROR_WEBGL_VAO_REJECTED.to_string(), + )); + }; + context.bind_vertex_array(Some(&vao)); + let texture_limit: u32 = texture_size_limit(&context); + let dimension_limit: u32 = texture_limit.min(viewport_dimension_limit(&context)); + let scene_texture: WebGlRayTraceTexture = match create_packed_texture( + &context, + WEBGL_TEXTURE_UNIT_SCENE, + WEBGL_PLACEHOLDER_TEXELS, + WEBGL_PLACEHOLDER_TEXELS, + ) { + Ok(texture) => texture, + Err(reason) => { + release_partial(&context, Some(&program), Some(&vao), None, None); + return Err(reason); + } + }; + let frame_texture: WebGlRayTraceTexture = match create_packed_texture( + &context, + WEBGL_TEXTURE_UNIT_FRAME, + WEBGL_PLACEHOLDER_TEXELS, + WEBGL_PLACEHOLDER_TEXELS, + ) { + Ok(texture) => texture, + Err(reason) => { + release_partial( + &context, + Some(&program), + Some(&vao), + Some(&scene_texture), + None, + ); + return Err(reason); + } + }; + let mut backend: WebGlRayTraceBackend = WebGlRayTraceBackend { + context, + program, + vao, + scene_texture, + frame_texture, + scene_uniform, + frame_uniform, + scene_scratch: Vec::new(), + frame_scratch: Vec::new(), + dimension_limit_value: dimension_limit, + }; + let resized: Result<(), JsValue> = backend.resize(width, height); + match resized { + Ok(()) => Ok(backend), + Err(reason) => { + backend.release(); + Err(reason) + } + } + } + + /// The largest surface dimension, in pixels, this context accepts. + /// + /// The value is the smaller of the implementation's + /// `MAX_TEXTURE_SIZE` and its `MAX_VIEWPORT_DIMS` pair: a surface + /// larger than either cannot be drawn into. It is a per-dimension + /// pixel count, not a byte budget. + /// + /// # Returns + /// + /// - `Option` - The limit, or `None` when the context exposes + /// none to enforce. + pub fn dimension_limit(&self) -> Option { + match self.get_dimension_limit_value() { + 0 => None, + limit => Some(limit), + } + } + + /// Validates a new surface size and applies the full-surface + /// viewport. + /// + /// The size is rejected before any state changes when the context is + /// lost or when either dimension exceeds the per-axis limit, so a + /// failed resize leaves the viewport, and therefore the previous, + /// still renderable, surface, exactly as it was. Neither data + /// texture is touched: they are sized by the packed blocks, not by + /// the surface, so a resize creates and destroys no GL object. + /// + /// # Arguments + /// + /// - `u32` - The surface width in pixels. + /// - `u32` - The surface height in pixels. + /// + /// # Returns + /// + /// - `Result<(), JsValue>` - `Ok` once the size is accepted. + pub fn resize(&mut self, width: u32, height: u32) -> Result<(), JsValue> { + validate_webgl_surface( + self.get_context(), + width, + height, + self.get_dimension_limit_value(), + )?; + apply_full_viewport(self.get_context()) + } + + /// Refills the frame texture and issues the fullscreen draw. + /// + /// Only the frame block is uploaded here. The scene block was + /// uploaded by [`WebGlRayTraceBackend::upload_scene`] and is left + /// untouched, so a steady frame moves no scene texel. + /// + /// The viewport is re-derived from the drawing buffer here rather + /// than trusting the one set at resize, because the browser resets + /// it whenever the canvas is resized — including the resize the + /// renderer performs after this backend accepted the new size — and + /// a stale viewport draws the fullscreen triangle into a corner. + /// + /// # Arguments + /// + /// - `&[f32]` - The packed frame block. + /// + /// # Returns + /// + /// - `Result<(), JsValue>` - `Ok` once the draw was issued. + pub fn draw(&mut self, frame: &[f32]) -> Result<(), JsValue> { + if self.get_context().is_context_lost() { + return Err(backend_error( + ERROR_WEBGL_CONTEXT_LOST, + ERROR_WEBGL_CONTEXT_LOST.to_string(), + )); + } + let context: WebGl2RenderingContext = self.get_context().clone(); + let mut scratch: Vec = mem::take(self.get_mut_frame_scratch()); + let outcome: Result<(), JsValue> = + ensure_frame_texture(&context, self.get_mut_frame_texture(), &mut scratch, frame); + self.set_frame_scratch(scratch); + outcome?; + apply_full_viewport(self.get_context())?; + self.get_context().use_program(Some(self.get_program())); + self.get_context().bind_vertex_array(Some(self.get_vao())); + bind_data_texture( + self.get_context(), + WEBGL_TEXTURE_UNIT_SCENE, + self.get_scene_texture().get_texture(), + )?; + bind_data_texture( + self.get_context(), + WEBGL_TEXTURE_UNIT_FRAME, + self.get_frame_texture().get_texture(), + )?; + self.get_context() + .uniform1i(Some(self.get_scene_uniform()), WEBGL_SAMPLER_UNIT_SCENE); + self.get_context() + .uniform1i(Some(self.get_frame_uniform()), WEBGL_SAMPLER_UNIT_FRAME); + require_clear_gl_error( + self.get_context(), + ERROR_WEBGL_CALL, + WEBGL_ERROR_SOURCE_BINDING, + )?; + self.get_context().draw_arrays( + WebGl2RenderingContext::TRIANGLES, + WEBGL_FIRST_VERTEX, + WEBGL_VERTEX_COUNT, + ); + require_clear_gl_error(self.get_context(), ERROR_WEBGL_CALL, "drawArrays") + } + + /// Uploads the packed scene block into its data texture. + /// + /// The texture grows to the shape the block needs when it outgrows + /// the current one, and keeps its allocation when it does not, so a + /// repeated update at a fixed size issues one `texSubImage2D` and + /// creates no GL object. Growth allocates the new texture, writes + /// the whole block into it, swaps it in, and only then deletes the + /// texture it replaced — a block the driver refuses leaves the + /// previous, still valid, scene bound and readable. + /// + /// # Arguments + /// + /// - `&[f32]` - The packed scene block. + /// + /// # Returns + /// + /// - `Result<(), JsValue>` - `Ok` once the block is live on the GPU. + pub fn upload_scene(&mut self, packed: &[f32]) -> Result<(), JsValue> { + if self.get_context().is_context_lost() { + return Err(backend_error( + ERROR_WEBGL_CONTEXT_LOST, + ERROR_WEBGL_CONTEXT_LOST.to_string(), + )); + } + let context: WebGl2RenderingContext = self.get_context().clone(); + let mut scratch: Vec = mem::take(self.get_mut_scene_scratch()); + let outcome: Result<(), JsValue> = + ensure_scene_texture(&context, self.get_mut_scene_texture(), &mut scratch, packed); + self.set_scene_scratch(scratch); + outcome + } + + /// Deletes every GL object the backend created. + /// + /// # Returns + /// + /// - `()` - Cleanup never fails; an object the driver already + /// reclaimed is simply not deleted again. A lost context has + /// already discarded every object, so none is deleted. + pub fn release(&mut self) { + if self.get_context().is_context_lost() { + return; + } + self.get_context() + .delete_texture(Some(self.get_scene_texture().get_texture())); + self.get_context() + .delete_texture(Some(self.get_frame_texture().get_texture())); + self.get_context().delete_vertex_array(Some(self.get_vao())); + self.get_context().delete_program(Some(self.get_program())); + } +} diff --git a/engine/src/raytracing/backend/webgl/mod.rs b/engine/src/raytracing/backend/webgl/mod.rs new file mode 100644 index 00000000..03035610 --- /dev/null +++ b/engine/src/raytracing/backend/webgl/mod.rs @@ -0,0 +1,10 @@ +mod r#const; +mod r#fn; +mod r#impl; +mod r#struct; + +pub(crate) use r#struct::*; + +pub(crate) use {r#const::*, r#fn::*}; + +use super::*; diff --git a/engine/src/raytracing/backend/webgl/struct.rs b/engine/src/raytracing/backend/webgl/struct.rs new file mode 100644 index 00000000..4c4932b5 --- /dev/null +++ b/engine/src/raytracing/backend/webgl/struct.rs @@ -0,0 +1,101 @@ +use super::*; + +/// A float data texture holding one packed block. +/// +/// The texture is allocated for the block that actually needs it: attach +/// creates a one-texel placeholder and the first upload that outgrows +/// it allocates the real size, so nothing reserves the whole addressable +/// budget up front. A later, larger block allocates one new texture, +/// swaps it in, and only then deletes the texture it replaced, which is +/// what makes a growth step transactional. The filters are `NEAREST` and +/// the wrap mode is `CLAMP_TO_EDGE` precisely because the block is read +/// with `texelFetch`, which must never interpolate or wrap. The texture +/// is only sampled, never rendered into, so no float colour-buffer +/// extension is involved. +#[derive(Clone, Data, Debug)] +pub(crate) struct WebGlRayTraceTexture { + /// The GL texture object. + #[get(pub(crate))] + #[get_mut(pub(crate))] + #[set(pub(crate))] + pub(crate) texture: WebGlTexture, + /// The texture width in texels, the row stride the shader divides by. + #[get(pub(crate), type(copy))] + #[set(pub(crate))] + #[get_mut(pub(crate))] + pub(crate) width: i32, + /// The texture height in texels. + #[get(pub(crate), type(copy))] + #[set(pub(crate))] + #[get_mut(pub(crate))] + pub(crate) height: i32, +} + +/// The WebGL 2 backend: a fullscreen triangle over two float data +/// textures. +/// +/// The context, program, vertex array, and both textures are created +/// once. Each frame is one `texSubImage2D` of the frame block, a +/// viewport derived from the drawing buffer, and one `drawArrays`; +/// nothing in the steady-state path allocates a GL object or a Rust +/// vector. The scene block is uploaded only by +/// [`WebGlRayTraceBackend::upload_scene`], never by the draw. +/// +/// The viewport is re-applied from the drawing buffer on every draw +/// because setting the canvas width or height resets it, and a resize +/// that leaves the viewport stale draws into a corner of the surface. +#[derive(Data, Debug)] +pub(crate) struct WebGlRayTraceBackend { + /// The WebGL 2 rendering context every call goes through. + #[get(pub(crate))] + #[get_mut(pub(crate))] + #[set(pub(crate))] + pub(crate) context: WebGl2RenderingContext, + /// The linked ray-trace program. + #[get(pub(crate))] + #[set(pub(crate))] + #[get_mut(pub(crate))] + pub(crate) program: WebGlProgram, + /// The vertex array object the fullscreen triangle is drawn through. + #[get(pub(crate))] + #[set(pub(crate))] + #[get_mut(pub(crate))] + pub(crate) vao: WebGlVertexArrayObject, + /// The packed scene data texture. + #[get(pub(crate))] + #[get_mut(pub(crate))] + #[set(pub(crate))] + pub(crate) scene_texture: WebGlRayTraceTexture, + /// The packed frame data texture. + #[get(pub(crate))] + #[get_mut(pub(crate))] + #[set(pub(crate))] + pub(crate) frame_texture: WebGlRayTraceTexture, + /// The cached sampler location of `u_scene`. + #[get(pub(crate))] + #[set(pub(crate))] + #[get_mut(pub(crate))] + pub(crate) scene_uniform: WebGlUniformLocation, + /// The cached sampler location of `u_frame`. + #[get(pub(crate))] + #[set(pub(crate))] + #[get_mut(pub(crate))] + pub(crate) frame_uniform: WebGlUniformLocation, + /// The zero-extended upload staging buffer for the scene texture, + /// reused so a steady scene costs no allocation. + #[get(pub(crate))] + #[get_mut(pub(crate))] + #[set(pub(crate))] + pub(crate) scene_scratch: Vec, + /// The zero-extended upload staging buffer for the frame texture. + #[get(pub(crate))] + #[get_mut(pub(crate))] + #[set(pub(crate))] + pub(crate) frame_scratch: Vec, + /// The largest surface dimension this context accepts, the smaller + /// of the texture and viewport bounds. + #[get(pub(crate), type(copy))] + #[set(pub(crate))] + #[get_mut(pub(crate))] + pub(crate) dimension_limit_value: u32, +} diff --git a/engine/src/raytracing/backend/webgpu/const.rs b/engine/src/raytracing/backend/webgpu/const.rs new file mode 100644 index 00000000..dcfcd547 --- /dev/null +++ b/engine/src/raytracing/backend/webgpu/const.rs @@ -0,0 +1,346 @@ +/// The `navigator` property key carrying the `GPU` object. +pub(crate) const BACKEND_WEBGPU_NAVIGATOR_KEY: &str = "gpu"; + +/// The method name reading the preferred canvas format. +pub(crate) const BACKEND_WEBGPU_PREFERRED_FORMAT: &str = "getPreferredCanvasFormat"; + +/// The lowercase `bgra8unorm` swap-chain format. +pub(crate) const BACKEND_WEBGPU_FORMAT_BGRA8_UNORM: &str = "bgra8unorm"; + +/// The property carrying a `GPUError`'s message. +pub(crate) const BACKEND_WEBGPU_ERROR_MESSAGE: &str = "message"; + +/// The `GPUCompilationMessageType` value that makes a message fatal. +pub(crate) const BACKEND_WEBGPU_MESSAGE_ERROR: &str = "error"; + +/// The debug label attached to the ray-trace shader module. +pub(crate) const BACKEND_WEBGPU_SHADER_LABEL: &str = "euv-raytrace"; + +/// The `GPUErrorFilter` value wrapping each resource allocation. +pub(crate) const BACKEND_WEBGPU_SCOPE_VALIDATION: &str = "validation"; + +/// The `GPUBufferUsage.STORAGE` flag both packed buffers carry. +pub(crate) const BACKEND_WEBGPU_USAGE_STORAGE: u32 = 128; + +/// The `GPUBufferUsage.COPY_DST` flag, so `writeBuffer` may refill a +/// packed buffer in place. +pub(crate) const BACKEND_WEBGPU_USAGE_COPY_DST: u32 = 8; + +/// The `GPUShaderStage.FRAGMENT` flag both binding entries declare, +/// because only the fragment stage reads them. +pub(crate) const BACKEND_WEBGPU_VISIBILITY_FRAGMENT: u32 = 2; + +/// The WebGPU bind-group binding index of the packed scene buffer. +pub(crate) const BACKEND_WEBGPU_BINDING_SCENE: u32 = 0; + +/// The WebGPU bind-group binding index of the packed frame buffer. +pub(crate) const BACKEND_WEBGPU_BINDING_FRAME: u32 = 1; + +/// The `@group` index the shader's two bindings live in. +pub(crate) const BACKEND_WEBGPU_BIND_GROUP_INDEX: u32 = 0; + +/// The number of vertices the WebGPU fullscreen triangle issues. +pub(crate) const BACKEND_WEBGPU_VERTEX_COUNT: u32 = 3; + +/// The instance count a fullscreen draw issues. +pub(crate) const BACKEND_WEBGPU_INSTANCE_COUNT: u32 = 1; + +/// The first vertex the fullscreen triangle starts at. +pub(crate) const BACKEND_WEBGPU_FIRST_VERTEX: u32 = 0; + +/// The first instance the fullscreen triangle starts at. +pub(crate) const BACKEND_WEBGPU_FIRST_INSTANCE: u32 = 0; + +/// The size in bytes of one `f32` slot in a packed storage buffer. +pub(crate) const BACKEND_WEBGPU_F32_SIZE: usize = 4; + +/// The byte size each packed storage buffer starts at before the +/// first real block decides its final capacity. +/// +/// The initial allocation exists so a bind group can reference both +/// buffers at all — a zero-sized buffer is not a valid binding — while +/// staying far below every adapter's storage limit. Pre-reserving the +/// whole 512 MiB packed budget would exceed the storage-binding limit +/// on the common adapters and fail the very first allocation. +pub(crate) const BACKEND_WEBGPU_INITIAL_BUFFER_BYTES: u32 = 4096; + +/// The `GPUTextureUsage.RENDER_ATTACHMENT` flag the swap chain needs. +pub(crate) const BACKEND_WEBGPU_TEXTURE_RENDER_ATTACHMENT: u32 = 16; + +/// The `GPUCanvasAlphaMode.opaque` value the context is configured with. +pub(crate) const BACKEND_WEBGPU_ALPHA_OPAQUE: &str = "opaque"; + +/// The `GPUBufferBindingType.read-only-storage` value. +pub(crate) const BACKEND_WEBGPU_BUFFER_READ_ONLY_STORAGE: &str = "read-only-storage"; + +/// The descriptor property naming the device. +pub(crate) const BACKEND_WEBGPU_PROPERTY_DEVICE: &str = "device"; + +/// The descriptor property naming the swap-chain format. +pub(crate) const BACKEND_WEBGPU_PROPERTY_FORMAT: &str = "format"; + +/// The descriptor property naming the alpha mode. +pub(crate) const BACKEND_WEBGPU_PROPERTY_ALPHA_MODE: &str = "alphaMode"; + +/// The descriptor property naming the texture usage. +pub(crate) const BACKEND_WEBGPU_PROPERTY_USAGE: &str = "usage"; + +/// The descriptor property naming a bind-group binding index. +pub(crate) const BACKEND_WEBGPU_PROPERTY_BINDING: &str = "binding"; + +/// The descriptor property naming a binding's shader stage visibility. +pub(crate) const BACKEND_WEBGPU_PROPERTY_VISIBILITY: &str = "visibility"; + +/// The descriptor property naming a binding layout's buffer layout. +pub(crate) const BACKEND_WEBGPU_PROPERTY_BUFFER: &str = "buffer"; + +/// The descriptor property naming a binding layout's type. +pub(crate) const BACKEND_WEBGPU_PROPERTY_TYPE: &str = "type"; + +/// The descriptor property naming a bind group's entries. +pub(crate) const BACKEND_WEBGPU_PROPERTY_ENTRIES: &str = "entries"; + +/// The descriptor property naming a bind group's layout. +pub(crate) const BACKEND_WEBGPU_PROPERTY_LAYOUT: &str = "layout"; + +/// The descriptor property naming a bind-group entry's resource. +pub(crate) const BACKEND_WEBGPU_PROPERTY_RESOURCE: &str = "resource"; + +/// The descriptor property naming a pipeline layout's bind-group layouts. +pub(crate) const BACKEND_WEBGPU_PROPERTY_BIND_GROUP_LAYOUTS: &str = "bindGroupLayouts"; + +/// The descriptor property naming a pipeline's vertex stage. +pub(crate) const BACKEND_WEBGPU_PROPERTY_VERTEX: &str = "vertex"; + +/// The descriptor property naming a pipeline's fragment stage. +pub(crate) const BACKEND_WEBGPU_PROPERTY_FRAGMENT: &str = "fragment"; + +/// The descriptor property naming a stage's shader module. +pub(crate) const BACKEND_WEBGPU_PROPERTY_MODULE: &str = "module"; + +/// The descriptor property naming a stage's colour targets. +pub(crate) const BACKEND_WEBGPU_PROPERTY_TARGETS: &str = "targets"; + +/// The descriptor property naming a colour target's format. +pub(crate) const BACKEND_WEBGPU_PROPERTY_FORMAT_TARGET: &str = "format"; + +/// The descriptor property naming a pipeline's primitive state. +pub(crate) const BACKEND_WEBGPU_PROPERTY_PRIMITIVE: &str = "primitive"; + +/// The descriptor property naming a primitive topology. +pub(crate) const BACKEND_WEBGPU_PROPERTY_TOPOLOGY: &str = "topology"; + +/// The primitive topology value a triangle list names. +pub(crate) const BACKEND_WEBGPU_TOPOLOGY_TRIANGLE_LIST: &str = "triangle-list"; + +/// The descriptor property naming a stage's shader entry point. +pub(crate) const BACKEND_WEBGPU_PROPERTY_ENTRY_POINT: &str = "entryPoint"; + +/// The descriptor property naming a render pass's colour attachments. +pub(crate) const BACKEND_WEBGPU_PROPERTY_COLOR_ATTACHMENTS: &str = "colorAttachments"; + +/// The descriptor property naming a colour attachment's view. +pub(crate) const BACKEND_WEBGPU_PROPERTY_VIEW: &str = "view"; + +/// The descriptor property naming a colour attachment's clear colour. +pub(crate) const BACKEND_WEBGPU_PROPERTY_CLEAR_VALUE: &str = "clearValue"; + +/// The descriptor property naming an attachment's load operation. +pub(crate) const BACKEND_WEBGPU_PROPERTY_LOAD_OP: &str = "loadOp"; + +/// The descriptor property naming an attachment's store operation. +pub(crate) const BACKEND_WEBGPU_PROPERTY_STORE_OP: &str = "storeOp"; + +/// The attachment load operation value. +pub(crate) const BACKEND_WEBGPU_LOAD_CLEAR: &str = "clear"; + +/// The attachment store operation value. +pub(crate) const BACKEND_WEBGPU_STORE_STORE: &str = "store"; + +/// The descriptor property naming a shader module's code. +pub(crate) const BACKEND_WEBGPU_PROPERTY_CODE: &str = "code"; + +/// The descriptor property naming a shader module's label. +pub(crate) const BACKEND_WEBGPU_PROPERTY_LABEL: &str = "label"; + +/// The descriptor property naming a buffer's size in bytes. +pub(crate) const BACKEND_WEBGPU_PROPERTY_SIZE: &str = "size"; + +/// The descriptor property naming a buffer's usage flags. +pub(crate) const BACKEND_WEBGPU_PROPERTY_USAGE_FLAGS: &str = "usage"; + +/// The property a compilation info carries its messages under. +pub(crate) const BACKEND_WEBGPU_PROPERTY_MESSAGES: &str = "messages"; + +/// The property a compilation message carries its kind under. +/// +/// Distinct from [`BACKEND_WEBGPU_PROPERTY_TYPE`], which names a +/// binding layout's dictionary type; both happen to be the string +/// `type`. +pub(crate) const BACKEND_WEBGPU_PROPERTY_MESSAGE_TYPE: &str = "type"; + +/// The property a compilation message carries its text under. +pub(crate) const BACKEND_WEBGPU_PROPERTY_MESSAGE_TEXT: &str = "message"; + +/// The property a compilation message carries its line under. +pub(crate) const BACKEND_WEBGPU_PROPERTY_LINE_NUM: &str = "lineNum"; + +/// The property carrying a device's submit queue. +/// +/// `GPUDevice.queue` is an attribute, not a method: looking it up and +/// calling the result throws `queue is not callable` before a single +/// buffer exists. +pub(crate) const BACKEND_WEBGPU_PROPERTY_QUEUE: &str = "queue"; + +/// The property carrying a device's limits. +/// +/// Like [`BACKEND_WEBGPU_PROPERTY_QUEUE`], `GPUDevice.limits` is an +/// attribute holding a `GPUSupportedLimits` dictionary. +pub(crate) const BACKEND_WEBGPU_PROPERTY_LIMITS: &str = "limits"; + +/// The property carrying a device's loss promise. +/// +/// `GPUDevice.lost` is always present, so its existence proves +/// nothing; only the promise's resolution says the device died. +pub(crate) const BACKEND_WEBGPU_PROPERTY_LOST: &str = "lost"; + +/// The `GPUDeviceLostInfo.reason` value of a deliberate destroy. +pub(crate) const BACKEND_WEBGPU_LOST_REASON: &str = "reason"; + +/// The method chaining a `device.lost` promise onto the loss handler. +pub(crate) const BACKEND_WEBGPU_METHOD_THEN: &str = "then"; + +/// The event name a device reports uncaptured errors under. +pub(crate) const BACKEND_WEBGPU_EVENT_UNCAPTURED: &str = "uncapturederror"; + +/// The `GPUUncapturedErrorEvent.error` property. +pub(crate) const BACKEND_WEBGPU_PROPERTY_ERROR: &str = "error"; + +/// The method registering an event listener on a device. +pub(crate) const BACKEND_WEBGPU_METHOD_ADD_LISTENER: &str = "addEventListener"; + +/// The method unregistering an event listener from a device. +/// +/// The `uncapturederror` listener owns a Rust closure, so it has to be +/// unregistered before that closure is dropped: a device that keeps +/// the listener would invoke an invalidated callback the next time any +/// other validation error fires. +pub(crate) const BACKEND_WEBGPU_METHOD_REMOVE_LISTENER: &str = "removeEventListener"; + +/// The limit naming the largest addressable storage binding, in bytes. +pub(crate) const BACKEND_WEBGPU_LIMIT_STORAGE_BINDING: &str = "maxStorageBufferBindingSize"; + +/// The limit naming the largest allocatable buffer, in bytes. +pub(crate) const BACKEND_WEBGPU_LIMIT_BUFFER_SIZE: &str = "maxBufferSize"; + +/// The limit naming the largest texture edge, in pixels. +/// +/// This one counts pixels, not bytes: it bounds `width` and `height` +/// separately, and is the only limit a surface size has to respect. +pub(crate) const BACKEND_WEBGPU_LIMIT_TEXTURE_2D: &str = "maxTextureDimension2D"; + +/// The `vs_main` entry point the vertex stage names. +/// +/// The WGSL module the pipeline compiles declares two entry points, +/// `vs_main` and `fs_main`, and no `main`: naming one constant for both +/// stages asks the vertex stage for a fragment entry point and the +/// browser rejects the whole pipeline. +pub(crate) const BACKEND_WEBGPU_ENTRY_VERTEX: &str = "vs_main"; + +/// The `fs_main` entry point the fragment stage names. +pub(crate) const BACKEND_WEBGPU_ENTRY_FRAGMENT: &str = "fs_main"; + +/// The method binding a pipeline to a render pass. +pub(crate) const BACKEND_WEBGPU_METHOD_SET_PIPELINE: &str = "setPipeline"; + +/// The method binding a bind group to a render pass. +pub(crate) const BACKEND_WEBGPU_METHOD_SET_BIND_GROUP: &str = "setBindGroup"; + +/// The method issuing a draw inside a render pass. +pub(crate) const BACKEND_WEBGPU_METHOD_DRAW: &str = "draw"; + +/// The method closing a render pass. +pub(crate) const BACKEND_WEBGPU_METHOD_END: &str = "end"; + +/// The method configuring a canvas context. +pub(crate) const BACKEND_WEBGPU_METHOD_CONFIGURE: &str = "configure"; + +/// The method releasing a canvas context's swap chain. +pub(crate) const BACKEND_WEBGPU_METHOD_UNCONFIGURE: &str = "unconfigure"; + +/// The method reading a context's swap-chain texture. +pub(crate) const BACKEND_WEBGPU_METHOD_CURRENT_TEXTURE: &str = "getCurrentTexture"; + +/// The method deriving a bindable view from a texture. +pub(crate) const BACKEND_WEBGPU_METHOD_CREATE_VIEW: &str = "createView"; + +/// The method requesting an adapter. +pub(crate) const BACKEND_WEBGPU_METHOD_REQUEST_ADAPTER: &str = "requestAdapter"; + +/// The method requesting a device. +pub(crate) const BACKEND_WEBGPU_METHOD_REQUEST_DEVICE: &str = "requestDevice"; + +/// The method opening a validation error scope. +pub(crate) const BACKEND_WEBGPU_METHOD_PUSH_SCOPE: &str = "pushErrorScope"; + +/// The method closing a validation error scope. +pub(crate) const BACKEND_WEBGPU_METHOD_POP_SCOPE: &str = "popErrorScope"; + +/// The method creating a buffer. +pub(crate) const BACKEND_WEBGPU_METHOD_CREATE_BUFFER: &str = "createBuffer"; + +/// The method creating a bind-group layout. +pub(crate) const BACKEND_WEBGPU_METHOD_CREATE_LAYOUT: &str = "createBindGroupLayout"; + +/// The method creating a bind group. +pub(crate) const BACKEND_WEBGPU_METHOD_CREATE_BIND_GROUP: &str = "createBindGroup"; + +/// The method creating a pipeline layout. +pub(crate) const BACKEND_WEBGPU_METHOD_CREATE_PIPELINE_LAYOUT: &str = "createPipelineLayout"; + +/// The method creating a render pipeline asynchronously. +pub(crate) const BACKEND_WEBGPU_METHOD_CREATE_PIPELINE: &str = "createRenderPipelineAsync"; + +/// The method creating a shader module. +pub(crate) const BACKEND_WEBGPU_METHOD_CREATE_MODULE: &str = "createShaderModule"; + +/// The method creating a command encoder. +pub(crate) const BACKEND_WEBGPU_METHOD_CREATE_ENCODER: &str = "createCommandEncoder"; + +/// The method reading a shader module's compilation info. +pub(crate) const BACKEND_WEBGPU_METHOD_COMPILATION_INFO: &str = "getCompilationInfo"; + +/// The method opening a render pass on an encoder. +pub(crate) const BACKEND_WEBGPU_METHOD_BEGIN_PASS: &str = "beginRenderPass"; + +/// The method finishing a command encoder. +pub(crate) const BACKEND_WEBGPU_METHOD_FINISH: &str = "finish"; + +/// The method submitting a command buffer. +pub(crate) const BACKEND_WEBGPU_METHOD_SUBMIT: &str = "submit"; + +/// The method writing a storage buffer. +pub(crate) const BACKEND_WEBGPU_METHOD_WRITE_BUFFER: &str = "writeBuffer"; + +/// The method destroying a buffer. +pub(crate) const BACKEND_WEBGPU_METHOD_DESTROY: &str = "destroy"; + +/// The error reported when the shader module compiled with errors. +pub(crate) const BACKEND_WEBGPU_ERROR_SHADER: &str = "the WGSL shader module is invalid"; + +/// The error reported when the render pipeline failed validation. +pub(crate) const BACKEND_WEBGPU_ERROR_PIPELINE: &str = "the WebGPU render pipeline is invalid"; + +/// The error reported when the swap chain produced no usable texture. +pub(crate) const BACKEND_WEBGPU_ERROR_TEXTURE: &str = "the WebGPU swap chain produced no texture"; + +/// The error reported when a texture yielded no bindable view. +pub(crate) const BACKEND_WEBGPU_ERROR_VIEW: &str = "the WebGPU texture produced no view"; + +/// The diagnostic used when the failure record is momentarily borrowed. +pub(crate) const WEBGPU_FAILURE_BUSY: &str = + "the WebGPU failure record is being read by another frame"; + +/// The diagnostic used when a popped scope reported no message at all. +pub(crate) const WEBGPU_SCOPE_SILENT: &str = + "the WebGPU device rejected the operation without a validation message"; diff --git a/engine/src/raytracing/backend/webgpu/fn.rs b/engine/src/raytracing/backend/webgpu/fn.rs new file mode 100644 index 00000000..1ea5dd70 --- /dev/null +++ b/engine/src/raytracing/backend/webgpu/fn.rs @@ -0,0 +1,978 @@ +use super::*; + +/// Describes a thrown [`JsValue`] without ever losing it. +/// +/// # Arguments +/// +/// - `&JsValue` - The thrown value. +/// +/// # Returns +/// +/// - `String` - The value rendered as a string, or a debug rendering +/// when the value is not convertible. +pub(crate) fn describe_webgpu(reason: &JsValue) -> String { + match reason.as_string() { + Some(text) if !text.is_empty() => text, + _ => format!("{reason:?}"), + } +} + +/// Builds a plain JS object from key/value pairs. +/// +/// Every WebGPU descriptor is built through this one constructor, so a +/// missing key is impossible by construction and no descriptor is +/// assembled half-populated. +/// +/// # Arguments +/// +/// - `&[(&str, JsValue)]` - The property names and values. +/// +/// # Returns +/// +/// - `Result` - The assembled object. +pub(crate) fn descriptor(pairs: &[(&str, JsValue)]) -> Result { + let object: Object = Object::new(); + for (key, value) in pairs { + Reflect::set(object.as_ref(), &JsValue::from_str(key), value)?; + } + Ok(object) +} + +/// Reads a property off a WebGPU handle without ever calling it. +/// +/// `queue` and `limits` are attributes on `GPUDevice`, not methods, so +/// they are read here rather than invoked: calling them throws +/// `queue is not callable` before the backend has allocated anything. +/// +/// # Arguments +/// +/// - `&JsValue` - The handle to read from. +/// - `&str` - The property name. +/// +/// # Returns +/// +/// - `Result` - The property value, or the reason the +/// read threw. +pub(crate) fn attribute(target: &JsValue, name: &str) -> Result { + let value: JsValue = match Reflect::get(target, &JsValue::from_str(name)) { + Ok(read) => read, + Err(reason) => { + return Err(backend_error( + ERROR_WEBGPU_CALL, + format!("{name} could not be read: {}", describe_webgpu(&reason)), + )); + } + }; + if value.is_undefined() || value.is_null() { + return Err(backend_error( + ERROR_WEBGPU_CALL, + format!("{name} is missing from this WebGPU object"), + )); + } + Ok(value) +} + +/// Looks up a method on a WebGPU handle. +/// +/// # Arguments +/// +/// - `&JsValue` - The handle to look the method up on. +/// - `&str` - The method name. +/// +/// # Returns +/// +/// - `Result` - The bound method, or the reason it is +/// absent. +pub(crate) fn method(target: &JsValue, name: &str) -> Result { + let key: JsValue = JsValue::from_str(name); + match Reflect::get(target, &key) { + Ok(value) => match value.dyn_into() { + Ok(function) => Ok(function), + Err(_) => Err(backend_error( + ERROR_WEBGPU_CALL, + format!("{name} is not callable"), + )), + }, + Err(reason) => Err(backend_error(ERROR_WEBGPU_CALL, describe_webgpu(&reason))), + } +} + +/// Invokes a looked-up method and propagates a throw as a +/// [`backend_error`](super::backend_error). +/// +/// # Arguments +/// +/// - `&JsValue` - The handle to call on. +/// - `&str` - The method name, used in the diagnostic. +/// - `&JsValue` - The single argument. +/// +/// # Returns +/// +/// - `Result` - The call's return value. +pub(crate) fn call1(target: &JsValue, name: &str, argument: &JsValue) -> Result { + let function: Function = method(target, name)?; + match function.call1(target, argument) { + Ok(value) => Ok(value), + Err(reason) => Err(backend_error( + ERROR_WEBGPU_CALL, + format!("{name} threw: {}", describe_webgpu(&reason)), + )), + } +} + +/// Calls a zero-argument method and propagates a throw. +/// +/// # Arguments +/// +/// - `&JsValue` - The handle to call on. +/// - `&str` - The method name. +/// +/// # Returns +/// +/// - `Result` - The call's return value. +pub(crate) fn call0(target: &JsValue, name: &str) -> Result { + let function: Function = method(target, name)?; + match function.call0(target) { + Ok(value) => Ok(value), + Err(reason) => Err(backend_error( + ERROR_WEBGPU_CALL, + format!("{name} threw: {}", describe_webgpu(&reason)), + )), + } +} + +/// Calls a method with two arguments. +/// +/// `setBindGroup` takes `(index, bindGroup)` plus an optional dynamic +/// offset array; passing an array as a single argument leaves `index` +/// undefined and the whole command buffer invalid. +/// +/// # Arguments +/// +/// - `&JsValue` - The handle to call on. +/// - `&str` - The method name. +/// - `&JsValue` - The first argument. +/// - `&JsValue` - The second argument. +/// +/// # Returns +/// +/// - `Result` - The call's return value. +pub(crate) fn call2( + target: &JsValue, + name: &str, + first: &JsValue, + second: &JsValue, +) -> Result { + let function: Function = method(target, name)?; + match function.call2(target, first, second) { + Ok(value) => Ok(value), + Err(reason) => Err(backend_error( + ERROR_WEBGPU_CALL, + format!("{name} threw: {}", describe_webgpu(&reason)), + )), + } +} + +/// Calls a method with four arguments. +/// +/// `draw` takes `(vertexCount, instanceCount, firstVertex, +/// firstInstance)`; handed one array instead, it reads `vertexCount` as +/// `undefined` and issues no primitives at all. +/// +/// # Arguments +/// +/// - `&JsValue` - The handle to call on. +/// - `&str` - The method name. +/// - `&JsValue` - The first argument. +/// - `&JsValue` - The second argument. +/// - `&JsValue` - The third argument. +/// - `&JsValue` - The fourth argument. +/// +/// # Returns +/// +/// - `Result` - The call's return value. +pub(crate) fn call4( + target: &JsValue, + name: &str, + first: &JsValue, + second: &JsValue, + third: &JsValue, + fourth: &JsValue, +) -> Result { + let function: Function = method(target, name)?; + match function.call4(target, first, second, third, fourth) { + Ok(value) => Ok(value), + Err(reason) => Err(backend_error( + ERROR_WEBGPU_CALL, + format!("{name} threw: {}", describe_webgpu(&reason)), + )), + } +} + +/// Awaits a JS promise, propagating its rejection. +/// +/// # Arguments +/// +/// - `JsValue` - The promise, or anything the call returned. +/// +/// # Returns +/// +/// - `Result` - The resolved value. +pub(crate) async fn await_promise(pending: JsValue) -> Result { + let promise: Promise = pending.unchecked_into(); + match JsFuture::from(promise).await { + Ok(value) => Ok(value), + Err(reason) => Err(backend_error(ERROR_WEBGPU_CALL, describe_webgpu(&reason))), + } +} + +/// Closes a validation error scope and reports what it caught. +/// +/// Popping is checked at all three of its layers rather than on a +/// best-effort basis, because the three failures mean different things +/// and two of them must not read as a clean scope. A throw from +/// `popErrorScope` means the scope was never closed at all; a rejected +/// promise means it was closed but its verdict is lost. Either way the +/// device never told this backend whether the scoped work was valid, so +/// both are errors in their own right instead of a `None` that would be +/// indistinguishable from "the scope was clean". +/// +/// Only the third layer is a verdict: a resolved `GPUError` is the +/// message the scope caught, and an absent one means the device +/// validated the scoped work and found nothing wrong with it. +/// +/// # Arguments +/// +/// - `&JsValue` - The device whose error-scope stack is popped. +/// +/// # Returns +/// +/// - `Result, JsValue>` - The validation message the +/// scope caught, or `None` when the scope was clean. +pub(crate) async fn pop_validation_scope(device: &JsValue) -> Result, JsValue> { + let pending: JsValue = call0(device, BACKEND_WEBGPU_METHOD_POP_SCOPE)?; + let resolved: JsValue = await_promise(pending).await?; + Ok(scope_message(&resolved)) +} + +/// Reads `navigator.gpu`, reporting a clear error when WebGPU is +/// absent. +/// +/// # Returns +/// +/// - `Result` - The `GPU` object, or the reason it is +/// unreachable. +pub(crate) fn navigator_gpu() -> Result { + let Some(window) = window() else { + return Err(backend_error( + ERROR_WEBGPU_UNAVAILABLE, + "no window is available".to_string(), + )); + }; + let navigator: Navigator = window.navigator(); + let key: JsValue = JsValue::from_str(BACKEND_WEBGPU_NAVIGATOR_KEY); + let lookup: Result = Reflect::get(navigator.as_ref(), &key); + let gpu: JsValue = match lookup { + Ok(value) => value, + Err(reason) => { + return Err(backend_error( + ERROR_WEBGPU_UNAVAILABLE, + describe_webgpu(&reason), + )); + } + }; + if gpu.is_undefined() || gpu.is_null() { + return Err(backend_error( + ERROR_WEBGPU_UNAVAILABLE, + ERROR_WEBGPU_UNAVAILABLE.to_string(), + )); + } + Ok(gpu) +} + +/// Reads the preferred canvas format from the `GPU` object. +/// +/// # Arguments +/// +/// - `&JsValue` - The `GPU` object. +/// +/// # Returns +/// +/// - `String` - The lowercase format name exactly as the browser +/// reported it, or `bgra8unorm` when it exposes no getter. +pub(crate) fn preferred_format(gpu: &JsValue) -> String { + match call0(gpu, BACKEND_WEBGPU_PREFERRED_FORMAT) { + Ok(value) => match value.as_string() { + Some(text) => text, + None => BACKEND_WEBGPU_FORMAT_BGRA8_UNORM.to_string(), + }, + Err(_) => BACKEND_WEBGPU_FORMAT_BGRA8_UNORM.to_string(), + } +} + +/// Configures the canvas context for the device and swap-chain format. +/// +/// The same format string configures the context and names the +/// pipeline's colour target, because a mismatch invalidates the whole +/// command buffer with no visible error. +/// +/// # Arguments +/// +/// - `&HtmlCanvasElement` - The canvas to configure. +/// - `&JsValue` - The device owning the swap chain. +/// - `&str` - The lowercase preferred format. +/// +/// # Returns +/// +/// - `Result` - The configured context. +pub(crate) fn configure_context( + canvas: &HtmlCanvasElement, + device: &JsValue, + format_name: &str, +) -> Result { + let lookup: Result, JsValue> = canvas.get_context(WEBGPU_CONTEXT_ID); + let context: JsValue = match lookup { + Ok(Some(object)) => object.into(), + Ok(None) => { + return Err(backend_error( + ERROR_WEBGPU_CONTEXT, + ERROR_WEBGPU_CONTEXT.to_string(), + )); + } + Err(reason) => { + return Err(backend_error( + ERROR_WEBGPU_CONTEXT, + describe_webgpu(&reason), + )); + } + }; + let configuration: Object = descriptor(&[ + (BACKEND_WEBGPU_PROPERTY_DEVICE, device.clone()), + ( + BACKEND_WEBGPU_PROPERTY_FORMAT, + JsValue::from_str(format_name), + ), + ( + BACKEND_WEBGPU_PROPERTY_ALPHA_MODE, + JsValue::from_str(BACKEND_WEBGPU_ALPHA_OPAQUE), + ), + ( + BACKEND_WEBGPU_PROPERTY_USAGE, + JsValue::from_f64(BACKEND_WEBGPU_TEXTURE_RENDER_ATTACHMENT as f64), + ), + ])?; + match call1( + &context, + BACKEND_WEBGPU_METHOD_CONFIGURE, + configuration.as_ref(), + ) { + Ok(_) => Ok(context), + Err(reason) => Err(backend_error( + ERROR_WEBGPU_CONTEXT, + describe_webgpu(&reason), + )), + } +} + +/// Rounds a packed float count up to the byte size a storage buffer +/// needs. +/// +/// # Arguments +/// +/// - `usize` - The packed float count. +/// +/// # Returns +/// +/// - `u32` - The byte size to allocate, saturated at the 32-bit +/// descriptor width. +pub(crate) fn packed_byte_length(floats: usize) -> u32 { + let bytes: u64 = (floats as u64).saturating_mul(BACKEND_WEBGPU_F32_SIZE as u64); + bytes.min(u32::MAX as u64) as u32 +} + +/// Reads a numeric property off a WebGPU handle. +/// +/// # Arguments +/// +/// - `&JsValue` - The handle to read from. +/// - `&str` - The property name. +/// +/// # Returns +/// +/// - `Option` - The numeric value, or `None` when it is absent or +/// not a number. +pub(crate) fn number_property(target: &JsValue, name: &str) -> Option { + match Reflect::get(target, &JsValue::from_str(name)) { + Ok(value) => value.as_f64(), + Err(_) => None, + } +} + +/// Reads one device limit in bytes or pixels. +/// +/// A limit below one, or one that does not fit the 32-bit descriptor +/// width the rest of the backend uses, is treated as absent rather than +/// clamped: a device reporting nonsense must not turn into a silently +/// wrong ceiling. +/// +/// # Arguments +/// +/// - `&JsValue` - The device to query. +/// - `&str` - The limit name. +/// +/// # Returns +/// +/// - `u32` - The limit, or zero when the device reports none. +pub(crate) fn device_limit(device: &JsValue, name: &str) -> u32 { + let limits: JsValue = match attribute(device, BACKEND_WEBGPU_PROPERTY_LIMITS) { + Ok(value) => value, + Err(_) => return 0, + }; + match number_property(&limits, name) { + Some(value) if value >= 1.0 && value <= u32::MAX as f64 => value as u32, + _ => 0, + } +} + +/// Registers the device's loss and uncaptured-error handlers. +/// +/// `GPUDevice.lost` is a promise that is always present, so its +/// existence proves nothing and must never be read as a liveness +/// signal. The reason arrives asynchronously instead, and the handler +/// below is what turns it into a state the next synchronous call can +/// reject with. The `uncapturederror` listener covers the other +/// direction: a validation failure that never throws is otherwise an +/// entirely silent black frame. +/// +/// The loss callback is a one-shot callback owned by JavaScript, built +/// with `Closure::once_into_js`. `device.lost` settles exactly once — +/// and destroying the device settles it — so the browser may invoke +/// the callback at any point up to and including +/// [`WebGpuRayTraceBackend::release`](super::WebGpuRayTraceBackend::release), +/// while the Rust data it captures is freed the moment it does. Keeping +/// it in a struct field instead is what produced `closure invoked +/// recursively or after being dropped`: the field was cleared before +/// `destroy`, while the browser still held the very callback being +/// torn down. +/// +/// The `uncapturederror` listener is not one-shot, so it is owned by +/// JavaScript too and its handle is returned so +/// [`unregister_error_listener`](super::unregister_error_listener) +/// can remove it: the device keeps a strong reference until then, so +/// the callback cannot outlive the Rust data it captures. +/// +/// # Arguments +/// +/// - `&JsValue` - The device to observe. +/// - `&Rc>>` - The record both handlers write. +/// +/// # Returns +/// +/// - `Result` - The registered `uncapturederror` +/// listener, which +/// [`unregister_error_listener`](super::unregister_error_listener) +/// must remove before the device is destroyed. +pub(crate) fn observe_device( + device: &JsValue, + failure: &Rc>>, +) -> Result { + let record: Rc>> = Rc::clone(failure); + let lost_handler: JsValue = Closure::once_into_js(move |info: JsValue| -> () { + record_loss(&record, &info); + }); + let lost: JsValue = attribute(device, BACKEND_WEBGPU_PROPERTY_LOST)?; + let chained: Function = method(&lost, BACKEND_WEBGPU_METHOD_THEN)?; + let pending: JsValue = match chained.call1(&lost, lost_handler.as_ref()) { + Ok(value) => value, + Err(reason) => { + return Err(backend_error( + ERROR_WEBGPU_CALL, + format!("device.lost.then threw: {}", describe_webgpu(&reason)), + )); + } + }; + // The chained promise settles with the loss reason itself and + // carries nothing this backend needs, so it is dropped rather than + // awaited: awaiting it here would report the loss as an error + // during construction, before the caller had a renderer to reject. + drop(pending); + register_error_listener(device, Rc::clone(failure)) +} + +/// Registers the `uncapturederror` listener on a device. +/// +/// # Arguments +/// +/// - `&JsValue` - The device to observe. +/// - `Rc>>` - The record the handler writes. +/// +/// # Returns +/// +/// - `Result` - The registered listener handle. +fn register_error_listener( + device: &JsValue, + failure: Rc>>, +) -> Result { + let function: Closure = Closure::wrap(Box::new(move |event: JsValue| { + record_error_event(&failure, &event); + }) as Box); + let function_value: JsValue = function.into_js_value(); + let add: Function = method(device, BACKEND_WEBGPU_METHOD_ADD_LISTENER)?; + let registered: Result = add.call3( + device, + &JsValue::from_str(BACKEND_WEBGPU_EVENT_UNCAPTURED), + function_value.as_ref(), + &JsValue::UNDEFINED, + ); + registered?; + Ok(function_value) +} + +/// Removes a registered `uncapturederror` listener from its device. +/// +/// The device holds its own strong reference to the listener until it +/// is removed, so leaving it registered after the backend released its +/// buffers would keep a Rust callback reachable from a device this +/// backend no longer owns. Removal happens while the device is still +/// alive because a destroyed device cannot invoke it, and a device +/// that rejects the removal is already unusable. +/// +/// # Arguments +/// +/// - `&JsValue` - The device to stop observing. +/// - `&JsValue` - The listener handle `register_error_listener` +/// returned. +pub(crate) fn unregister_error_listener(device: &JsValue, listener: &JsValue) { + if let Ok(remove) = method(device, BACKEND_WEBGPU_METHOD_REMOVE_LISTENER) { + let removed: Result = remove.call2( + device, + &JsValue::from_str(BACKEND_WEBGPU_EVENT_UNCAPTURED), + listener, + ); + let _: Result = removed; + } +} + +/// Records the diagnostic an `uncapturederror` event carries. +/// +/// # Arguments +/// +/// - `&Rc>>` - The shared record. +/// - `&JsValue` - The dispatched event. +fn record_error_event(failure: &Rc>>, event: &JsValue) { + let detail: String = match attribute(event, BACKEND_WEBGPU_PROPERTY_ERROR) { + Ok(error) => scope_message(&error).unwrap_or_else(|| WEBGPU_SCOPE_SILENT.to_string()), + Err(reason) => describe_webgpu(&reason), + }; + record_failure(failure, detail); +} + +/// Records a device loss as a terminal failure. +/// +/// The loss overwrites any transient validation error already in the +/// record. Recording only when the record is empty would let a +/// `uncapturederror` from an earlier command mask the loss entirely — +/// and that record *is* drained, so the device loss would disappear +/// with it and later calls would report success on a dead device. A +/// loss is strictly more severe than anything already recorded, so it +/// is the one event allowed to replace what is there. +/// +/// # Arguments +/// +/// - `&Rc>>` - The shared record. +/// - `&JsValue` - The resolved `GPUDeviceLostInfo`. +fn record_loss(failure: &Rc>>, info: &JsValue) { + let detail: String = lost_reason(info); + if let Ok(mut cell) = failure.try_borrow_mut() { + *cell = Some(detail); + } +} + +/// Records a failure the first time it is seen. +/// +/// # Arguments +/// +/// - `&Rc>>` - The shared record. +/// - `String` - The diagnostic to remember. +fn record_failure(failure: &Rc>>, detail: String) { + if let Ok(mut cell) = failure.try_borrow_mut() + && cell.is_none() + { + *cell = Some(detail); + } +} + +/// Extracts the reason a device was lost. +/// +/// # Arguments +/// +/// - `&JsValue` - The resolved `GPUDeviceLostInfo`. +/// +/// # Returns +/// +/// - `String` - The reason, or the placeholder when the object carried +/// none. +pub(crate) fn lost_reason(info: &JsValue) -> String { + match attribute(info, BACKEND_WEBGPU_LOST_REASON) { + Ok(reason) => match reason.as_string() { + Some(text) if !text.is_empty() => { + format!("{ERROR_WEBGPU_DEVICE_LOST}: {text}") + } + _ => ERROR_WEBGPU_DEVICE_LOST.to_string(), + }, + Err(reason) => format!("{ERROR_WEBGPU_DEVICE_LOST}: {}", describe_webgpu(&reason)), + } +} + +/// Reports whether a recorded diagnostic describes a lost device. +/// +/// The record mixes two events that need opposite lifetimes: a lost +/// device is permanent, while an uncaptured validation error concerns +/// one command. Telling them apart by the prefix the loss handler +/// writes keeps the permanent one permanent without adding a second +/// field whose two halves could disagree. +/// +/// # Arguments +/// +/// - `&str` - The recorded diagnostic. +/// +/// # Returns +/// +/// - `bool` - `true` when the device itself was lost. +pub(crate) fn is_device_lost(recorded: &str) -> bool { + recorded.starts_with(ERROR_WEBGPU_DEVICE_LOST) +} + +/// Rejects an operation because the browser already reported the +/// device lost or the last command invalid. +/// +/// # Arguments +/// +/// - `Option` - The recorded failure, when one is pending. +/// +/// # Returns +/// +/// - `Result<(), JsValue>` - `Err` carrying the recorded failure, or +/// `Ok` when nothing is pending. +pub(crate) fn rejection(pending: Option) -> Result<(), JsValue> { + match pending { + Some(detail) => Err(backend_error(ERROR_WEBGPU_DEVICE_LOST, detail)), + None => Ok(()), + } +} + +/// Populates a popped error scope with the validation message. +/// +/// # Arguments +/// +/// - `&JsValue` - The resolved `popErrorScope` value. +/// +/// # Returns +/// +/// - `Option` - The message, or `None` when the scope was +/// clean. +pub(crate) fn scope_message(resolved: &JsValue) -> Option { + if resolved.is_null() || resolved.is_undefined() { + return None; + } + match Reflect::get(resolved, &JsValue::from_str(BACKEND_WEBGPU_ERROR_MESSAGE)) { + Ok(message) => match message.as_string() { + Some(text) if !text.is_empty() => Some(text), + _ => Some(WEBGPU_SCOPE_SILENT.to_string()), + }, + Err(_) => Some(WEBGPU_SCOPE_SILENT.to_string()), + } +} + +/// Joins the shader compilation errors into one diagnostic. +/// +/// Only `error`-kind messages are fatal; a warning must not fail an +/// otherwise usable module. +/// +/// # Arguments +/// +/// - `&JsValue` - The resolved compilation info. +/// +/// # Returns +/// +/// - `String` - Every error message, or an empty string when the module +/// compiled cleanly. +pub(crate) fn compilation_messages(info: &JsValue) -> String { + let messages: JsValue = + match Reflect::get(info, &JsValue::from_str(BACKEND_WEBGPU_PROPERTY_MESSAGES)) { + Ok(value) => value, + Err(_) => return String::new(), + }; + let Ok(list) = messages.dyn_into::() else { + return String::new(); + }; + let mut collected: String = String::new(); + for entry in list.iter() { + let kind: String = match Reflect::get( + &entry, + &JsValue::from_str(BACKEND_WEBGPU_PROPERTY_MESSAGE_TYPE), + ) { + Ok(value) => value.as_string().unwrap_or_default(), + Err(_) => String::new(), + }; + if kind != BACKEND_WEBGPU_MESSAGE_ERROR { + continue; + } + let text: String = match Reflect::get( + &entry, + &JsValue::from_str(BACKEND_WEBGPU_PROPERTY_MESSAGE_TEXT), + ) { + Ok(value) => value.as_string().unwrap_or_default(), + Err(_) => String::new(), + }; + let line: f64 = Reflect::get(&entry, &JsValue::from_str(BACKEND_WEBGPU_PROPERTY_LINE_NUM)) + .ok() + .and_then(|value: JsValue| value.as_f64()) + .unwrap_or(0.0); + collected.push_str(&format!("line {}: {text}\n", line as i64)); + } + collected +} + +/// Writes a packed block into a storage buffer in place. +/// +/// The block is exposed to JS as a view over wasm linear memory rather +/// than copied into a fresh typed array: `writeBuffer` copies the bytes +/// synchronously, so a copy per frame would double the traffic of every +/// upload for nothing. No allocation and no `await` sit between the view +/// and the call that consumes it, so the memory cannot move underneath +/// it. +/// +/// # Arguments +/// +/// - `&JsValue` - The queue to write through. +/// - `&JsValue` - The destination buffer. +/// - `&[f32]` - The packed block. +/// +/// # Returns +/// +/// - `Result<(), JsValue>` - `Ok` once the write was issued. +pub(crate) fn write_packed( + queue: &JsValue, + buffer: &JsValue, + packed: &[f32], +) -> Result<(), JsValue> { + if packed.is_empty() { + return Ok(()); + } + // SAFETY: the view aliases `packed` and is consumed by the + // `writeBuffer` call on the next two lines. Nothing allocates, is + // yielded to, or runs arbitrary JS in between, so linear memory + // cannot grow and detach the view before `writeBuffer` has copied + // it. + let view: Float32Array = unsafe { Float32Array::view(packed) }; + let function: Function = method(queue, BACKEND_WEBGPU_METHOD_WRITE_BUFFER)?; + match function.call3(queue, buffer, &JsValue::from_f64(0.0), view.as_ref()) { + Ok(_) => Ok(()), + Err(reason) => Err(backend_error( + ERROR_WEBGPU_CALL, + format!("writeBuffer threw: {}", describe_webgpu(&reason)), + )), + } +} + +/// Destroys one buffer, reporting a failure instead of dropping it. +/// +/// # Arguments +/// +/// - `&JsValue` - The buffer to destroy. +/// +/// # Returns +/// +/// - `Result<(), JsValue>` - `Ok` once the buffer is destroyed. +pub(crate) fn destroy_buffer(buffer: &JsValue) -> Result<(), JsValue> { + match call0(buffer, BACKEND_WEBGPU_METHOD_DESTROY) { + Ok(_) => Ok(()), + Err(reason) => Err(backend_error(ERROR_WEBGPU_CALL, describe_webgpu(&reason))), + } +} + +/// Releases a device this backend requested. +/// +/// # Arguments +/// +/// - `&JsValue` - The device to destroy. +pub(crate) fn destroy_device(device: &JsValue) { + let _: Result = call0(device, BACKEND_WEBGPU_METHOD_DESTROY); +} + +/// Releases everything a partially built backend has acquired so far. +/// +/// WebGPU allocates nothing at `requestDevice` time, so a failure +/// after the device exists leaks exactly that device. Anything acquired +/// before the failure is released too, so a retry starts from a clean +/// slate instead of compounding the loss. +/// +/// # Arguments +/// +/// - `&JsValue` - The device to destroy. +/// - `Option<&JsValue>` - The scene buffer, when one exists. +/// - `Option<&JsValue>` - The frame buffer, when one exists. +/// - `Option<&JsValue>` - The configured context, when one exists. +/// +/// # Returns +/// +/// - `()` - Cleanup never fails; a half-built backend has no caller to +/// report to. +pub(crate) fn abandon( + device: &JsValue, + scene: Option<&JsValue>, + frame: Option<&JsValue>, + context: Option<&JsValue>, +) { + if let Some(buffer) = scene { + let _: Result<(), JsValue> = destroy_buffer(buffer); + } + if let Some(buffer) = frame { + let _: Result<(), JsValue> = destroy_buffer(buffer); + } + if let Some(surface) = context { + let _: Result = call0(surface, BACKEND_WEBGPU_METHOD_UNCONFIGURE); + } + destroy_device(device); +} + +/// Builds the bind-group layout both packed buffers declare. +/// +/// # Arguments +/// +/// - `&JsValue` - The device to declare the layout on. +/// +/// # Returns +/// +/// - `Result` - The layout object. +pub(crate) fn packed_layout(device: &JsValue) -> Result { + let scene_entry: Object = descriptor(&[ + ( + BACKEND_WEBGPU_PROPERTY_BINDING, + JsValue::from_f64(BACKEND_WEBGPU_BINDING_SCENE as f64), + ), + ( + BACKEND_WEBGPU_PROPERTY_VISIBILITY, + JsValue::from_f64(BACKEND_WEBGPU_VISIBILITY_FRAGMENT as f64), + ), + ( + BACKEND_WEBGPU_PROPERTY_BUFFER, + descriptor(&[( + BACKEND_WEBGPU_PROPERTY_TYPE, + JsValue::from_str(BACKEND_WEBGPU_BUFFER_READ_ONLY_STORAGE), + )])? + .into(), + ), + ])?; + let frame_entry: Object = descriptor(&[ + ( + BACKEND_WEBGPU_PROPERTY_BINDING, + JsValue::from_f64(BACKEND_WEBGPU_BINDING_FRAME as f64), + ), + ( + BACKEND_WEBGPU_PROPERTY_VISIBILITY, + JsValue::from_f64(BACKEND_WEBGPU_VISIBILITY_FRAGMENT as f64), + ), + ( + BACKEND_WEBGPU_PROPERTY_BUFFER, + descriptor(&[( + BACKEND_WEBGPU_PROPERTY_TYPE, + JsValue::from_str(BACKEND_WEBGPU_BUFFER_READ_ONLY_STORAGE), + )])? + .into(), + ), + ])?; + let entries: Array = Array::of2(&scene_entry, &frame_entry); + let layout_descriptor: Object = + descriptor(&[(BACKEND_WEBGPU_PROPERTY_ENTRIES, entries.into())])?; + call1( + device, + BACKEND_WEBGPU_METHOD_CREATE_LAYOUT, + layout_descriptor.as_ref(), + ) +} + +/// Builds the bind group pairing both packed storage buffers. +/// +/// The resource is wrapped in `{ buffer }` rather than passed bare. +/// Newer specifications admit the shorthand, but the explicit form is +/// the one every shipping browser accepts, and a bare handle here +/// invalidates the group rather than raising anything catchable. +/// +/// # Arguments +/// +/// - `&JsValue` - The device to create the bind group on. +/// - `&JsValue` - The packed scene buffer. +/// - `&JsValue` - The packed frame buffer. +/// - `&JsValue` - The persistent layout. +/// +/// # Returns +/// +/// - `Result` - The bind group. +pub(crate) fn bind_group_of( + device: &JsValue, + scene: &JsValue, + frame: &JsValue, + layout: &JsValue, +) -> Result { + let scene_resource: Object = descriptor(&[(BACKEND_WEBGPU_PROPERTY_BUFFER, scene.clone())])?; + let frame_resource: Object = descriptor(&[(BACKEND_WEBGPU_PROPERTY_BUFFER, frame.clone())])?; + let scene_entry: Object = descriptor(&[ + ( + BACKEND_WEBGPU_PROPERTY_BINDING, + JsValue::from_f64(BACKEND_WEBGPU_BINDING_SCENE as f64), + ), + (BACKEND_WEBGPU_PROPERTY_RESOURCE, scene_resource.into()), + ])?; + let frame_entry: Object = descriptor(&[ + ( + BACKEND_WEBGPU_PROPERTY_BINDING, + JsValue::from_f64(BACKEND_WEBGPU_BINDING_FRAME as f64), + ), + (BACKEND_WEBGPU_PROPERTY_RESOURCE, frame_resource.into()), + ])?; + let group_descriptor: Object = descriptor(&[ + ( + BACKEND_WEBGPU_PROPERTY_ENTRIES, + Array::of2(&scene_entry, &frame_entry).into(), + ), + (BACKEND_WEBGPU_PROPERTY_LAYOUT, layout.clone()), + ])?; + call1( + device, + BACKEND_WEBGPU_METHOD_CREATE_BIND_GROUP, + group_descriptor.as_ref(), + ) +} + +/// Builds the single colour attachment a fullscreen draw renders +/// into, cleared to opaque black. +/// +/// # Arguments +/// +/// - `&JsValue` - The swap-chain texture's view. +/// +/// # Returns +/// +/// - `Result` - The attachment descriptor. +pub(crate) fn clear_attachment(view: &JsValue) -> Result { + let channel: JsValue = JsValue::from_f64(0.0); + let opaque: JsValue = JsValue::from_f64(1.0); + let red: JsValue = channel.clone(); + let green: JsValue = channel.clone(); + let blue: JsValue = channel; + let clear: JsValue = Array::of4(&red, &green, &blue, &opaque).into(); + descriptor(&[ + (BACKEND_WEBGPU_PROPERTY_VIEW, view.clone()), + (BACKEND_WEBGPU_PROPERTY_CLEAR_VALUE, clear), + ( + BACKEND_WEBGPU_PROPERTY_LOAD_OP, + JsValue::from_str(BACKEND_WEBGPU_LOAD_CLEAR), + ), + ( + BACKEND_WEBGPU_PROPERTY_STORE_OP, + JsValue::from_str(BACKEND_WEBGPU_STORE_STORE), + ), + ]) +} diff --git a/engine/src/raytracing/backend/webgpu/impl.rs b/engine/src/raytracing/backend/webgpu/impl.rs new file mode 100644 index 00000000..71960d28 --- /dev/null +++ b/engine/src/raytracing/backend/webgpu/impl.rs @@ -0,0 +1,781 @@ +use super::*; + +/// Wraps an allocation in a validation error scope, then reports what +/// the scope caught. +/// +/// WebGPU reports allocation failures asynchronously, so the scope is +/// popped and read before the resource is handed back. An allocation +/// that the device rejected is an error, never a silently unusable +/// handle. +/// +/// The allocation's own outcome is kept separate from the scope's: a +/// throw here must stay an `Err` even though the scope still has to be +/// popped first, so the device is not left with an unbalanced stack of +/// error scopes. Popping is delegated to +/// [`pop_validation_scope`](super::pop_validation_scope), so a scope +/// this helper cannot read is an error rather than a clean result. +/// +/// An error scope cannot cover an asynchronous creation. The WebGPU +/// specification resolves `createRenderPipelineAsync` with a +/// `GPUPipelineError` and reports **no** `GPUError` to any error scope, +/// so a scope popped before the promise settles is guaranteed to be +/// empty whether the pipeline was accepted or rejected. Callers whose +/// allocation returns a promise must therefore await it themselves; +/// only a synchronous allocation may pass through this helper. +/// +/// # Arguments +/// +/// - `&JsValue` - The device owning the scope. +/// - `F` - The allocation to perform, bound to return the resource the +/// scope must then vouch for. +/// +/// # Returns +/// +/// - `Result` - The allocated resource, or the +/// validation message the scope reported. +async fn scoped(device: &JsValue, allocate: F) -> Result +where + F: FnOnce() -> Result, +{ + let scope_argument: JsValue = JsValue::from_str(BACKEND_WEBGPU_SCOPE_VALIDATION); + let pushed: Result = + call1(device, BACKEND_WEBGPU_METHOD_PUSH_SCOPE, &scope_argument); + pushed?; + let allocation: Result = allocate(); + let observed: Result, JsValue> = pop_validation_scope(device).await; + let resource: JsValue = allocation?; + let scope: Option = observed?; + match scope { + Some(message) => Err(backend_error(ERROR_WEBGPU_CALL, message)), + None => Ok(resource), + } +} + +/// Creates the render pipeline and awaits its asynchronous creation. +/// +/// `createRenderPipelineAsync` returns a promise rather than a +/// pipeline, so the promise *is* the pipeline as far as every later +/// call is concerned: handing `setPipeline` the pending promise fails +/// with `parameter 1 is not of type 'GPURenderPipeline'` while the +/// rejection lands in an unhandled rejection instead of as an `Err`. +/// +/// The promise is therefore awaited here, before the descriptor's +/// error scope is popped, for two reasons. Awaiting attaches the +/// rejection handler in the same synchronous step that created the +/// promise, so a browser that rejects during the same task still finds +/// a handler and never raises an unhandled rejection. Popping the +/// scope first would leave an await point between the creation and its +/// handler, which is exactly the window in which an unobserved +/// rejection is reported. +/// +/// The scope is still popped either way: a rejection leaves the +/// device's scope stack unbalanced otherwise, and the inner `Err` is +/// kept rather than replaced by whatever the scope reported. Popping +/// is therefore not best-effort — the `popErrorScope` call, the +/// promise it returns, and the `GPUError` that promise resolves to are +/// each checked, because a scope this backend pushed and then failed +/// to read is indistinguishable from one the device never validated. +/// +/// Both layers matter for different failures. A throw from +/// `popErrorScope` means the scope was never closed, and a rejected +/// `popErrorScope` promise means it was closed but its result is +/// lost; either way the scope's own verdict on the creation is +/// unknown, so a pipeline accepted on a device that could not report +/// is never treated as validated. The pipeline's own outcome still +/// wins, because a `GPUPipelineError` names the real failure while a +/// scope message may describe any validation the descriptor provoked. +/// +/// # Arguments +/// +/// - `&JsValue` - The device to create the pipeline on. +/// - `&JsValue` - The compiled WGSL module. +/// - `&JsValue` - The layout both packed buffers bind against. +/// - `&str` - The swap-chain format the fragment stage must target. +/// +/// # Returns +/// +/// - `Result` - The resolved pipeline, or the reason +/// the browser rejected creating it. +async fn create_render_pipeline( + device: &JsValue, + module: &JsValue, + layout: &JsValue, + format: &str, +) -> Result { + let layout_list: Array = Array::new(); + layout_list.push(layout.as_ref()); + let layout_descriptor: Object = descriptor(&[( + BACKEND_WEBGPU_PROPERTY_BIND_GROUP_LAYOUTS, + layout_list.into(), + )])?; + let pipeline_layout: JsValue = call1( + device, + BACKEND_WEBGPU_METHOD_CREATE_PIPELINE_LAYOUT, + layout_descriptor.as_ref(), + )?; + let stage_descriptor: Object = descriptor(&[ + (BACKEND_WEBGPU_PROPERTY_MODULE, module.clone()), + ( + BACKEND_WEBGPU_PROPERTY_ENTRY_POINT, + JsValue::from_str(BACKEND_WEBGPU_ENTRY_VERTEX), + ), + ])?; + let stage: JsValue = stage_descriptor.into(); + let target_descriptor: Object = descriptor(&[( + BACKEND_WEBGPU_PROPERTY_FORMAT_TARGET, + JsValue::from_str(format), + )])?; + let targets: Array = Array::new(); + targets.push(target_descriptor.as_ref()); + let fragment_descriptor: Object = descriptor(&[ + (BACKEND_WEBGPU_PROPERTY_MODULE, module.clone()), + ( + BACKEND_WEBGPU_PROPERTY_ENTRY_POINT, + JsValue::from_str(BACKEND_WEBGPU_ENTRY_FRAGMENT), + ), + (BACKEND_WEBGPU_PROPERTY_TARGETS, targets.into()), + ])?; + let fragment: JsValue = fragment_descriptor.into(); + let primitive_descriptor: Object = descriptor(&[( + BACKEND_WEBGPU_PROPERTY_TOPOLOGY, + JsValue::from_str(BACKEND_WEBGPU_TOPOLOGY_TRIANGLE_LIST), + )])?; + let primitive: JsValue = primitive_descriptor.into(); + let pairs: [(&str, JsValue); 4] = [ + (BACKEND_WEBGPU_PROPERTY_LAYOUT, pipeline_layout), + (BACKEND_WEBGPU_PROPERTY_VERTEX, stage), + (BACKEND_WEBGPU_PROPERTY_FRAGMENT, fragment), + (BACKEND_WEBGPU_PROPERTY_PRIMITIVE, primitive), + ]; + let scope_argument: JsValue = JsValue::from_str(BACKEND_WEBGPU_SCOPE_VALIDATION); + let pushed: Result = + call1(device, BACKEND_WEBGPU_METHOD_PUSH_SCOPE, &scope_argument); + pushed?; + let created: Result = match descriptor(&pairs) { + Ok(pipeline_descriptor) => call1( + device, + BACKEND_WEBGPU_METHOD_CREATE_PIPELINE, + pipeline_descriptor.as_ref(), + ), + Err(reason) => Err(reason), + }; + let resolved: Result = match created { + Ok(pending) => await_promise(pending).await, + Err(reason) => Err(reason), + }; + let observed: Result, JsValue> = pop_validation_scope(device).await; + let pipeline: JsValue = resolved?; + if pipeline.is_undefined() || pipeline.is_null() { + return Err(backend_error( + BACKEND_WEBGPU_ERROR_PIPELINE, + "createRenderPipelineAsync returned nothing".to_string(), + )); + } + let validation: Option = observed?; + if let Some(message) = validation { + return Err(backend_error(ERROR_WEBGPU_CALL, message)); + } + Ok(pipeline) +} + +/// Creates one storage buffer under a popped-and-inspected error scope. +/// +/// # Arguments +/// +/// - `&JsValue` - The device to allocate from. +/// - `u32` - The byte size to allocate. +/// +/// # Returns +/// +/// - `Result` - The buffer, or the validation message +/// the scope reported. +async fn create_storage_buffer(device: &JsValue, bytes: u32) -> Result { + let usage: u32 = BACKEND_WEBGPU_USAGE_STORAGE | BACKEND_WEBGPU_USAGE_COPY_DST; + let pairs: [(&str, JsValue); 2] = [ + ( + BACKEND_WEBGPU_PROPERTY_SIZE, + JsValue::from_f64(bytes as f64), + ), + ( + BACKEND_WEBGPU_PROPERTY_USAGE_FLAGS, + JsValue::from_f64(usage as f64), + ), + ]; + match scoped(device, || -> Result { + let descriptor: Object = descriptor(&pairs)?; + call1( + device, + BACKEND_WEBGPU_METHOD_CREATE_BUFFER, + descriptor.as_ref(), + ) + }) + .await + { + Ok(buffer) if !buffer.is_undefined() => Ok(buffer), + Ok(_) => Err(backend_error( + ERROR_WEBGPU_CALL, + "createBuffer returned nothing".to_string(), + )), + Err(reason) => Err(reason), + } +} + +/// Creates the shader module and awaits its compilation info. +/// +/// A WGSL module compiles into an object even when it is invalid, so the +/// compiler's own errors are read from `getCompilationInfo` rather than +/// inferred from the absence of a throw. +/// +/// # Arguments +/// +/// - `&JsValue` - The device to compile on. +/// - `&str` - The WGSL source. +/// +/// # Returns +/// +/// - `Result` - The compiled module, or the compiler's +/// own errors. +async fn create_shader_module(device: &JsValue, source: &str) -> Result { + let pairs: [(&str, JsValue); 2] = [ + (BACKEND_WEBGPU_PROPERTY_CODE, JsValue::from_str(source)), + ( + BACKEND_WEBGPU_PROPERTY_LABEL, + JsValue::from_str(BACKEND_WEBGPU_SHADER_LABEL), + ), + ]; + let module: JsValue = scoped(device, || -> Result { + let module_descriptor: Object = descriptor(&pairs)?; + call1( + device, + BACKEND_WEBGPU_METHOD_CREATE_MODULE, + module_descriptor.as_ref(), + ) + }) + .await?; + if module.is_undefined() || module.is_null() { + return Err(backend_error( + BACKEND_WEBGPU_ERROR_SHADER, + "createShaderModule returned nothing".to_string(), + )); + } + let pending: JsValue = call0(&module, BACKEND_WEBGPU_METHOD_COMPILATION_INFO)?; + let resolved: JsValue = await_promise(pending).await?; + let errors: String = compilation_messages(&resolved); + if !errors.is_empty() { + return Err(backend_error(BACKEND_WEBGPU_ERROR_SHADER, errors)); + } + Ok(module) +} + +/// Requests the WebGPU adapter, refusing to fall back. +/// +/// # Arguments +/// +/// - `&JsValue` - The `GPU` object. +/// +/// # Returns +/// +/// - `Result` - The adapter, or the reason none is +/// available. +async fn request_adapter(gpu: &JsValue) -> Result { + let pending: JsValue = call0(gpu, BACKEND_WEBGPU_METHOD_REQUEST_ADAPTER)?; + let resolved: JsValue = await_promise(pending).await?; + if resolved.is_null() || resolved.is_undefined() { + return Err(backend_error( + ERROR_WEBGPU_ADAPTER, + ERROR_WEBGPU_NO_ADAPTER.to_string(), + )); + } + Ok(resolved) +} + +/// Requests the device from an adapter. +/// +/// # Arguments +/// +/// - `&JsValue` - The adapter to request from. +/// +/// # Returns +/// +/// - `Result` - The device, or the reason the request +/// produced none. +async fn request_device(adapter: &JsValue) -> Result { + let empty: Object = Object::new(); + let pending: JsValue = call1( + adapter, + BACKEND_WEBGPU_METHOD_REQUEST_DEVICE, + empty.as_ref(), + )?; + let resolved: JsValue = await_promise(pending).await?; + if resolved.is_null() || resolved.is_undefined() { + return Err(backend_error( + ERROR_WEBGPU_DEVICE, + ERROR_WEBGPU_DEVICE.to_string(), + )); + } + Ok(resolved) +} + +impl WebGpuRayTraceBackend { + /// Acquires the device, compiles the shader, and builds the + /// pipeline, bind group, and both packed buffers. + /// + /// Everything WebGPU validates asynchronously is awaited here: the + /// shader's compilation info, the pipeline creation, and every + /// allocation's error scope. An invalid shader or pipeline is an + /// error returned from `attach` rather than a black frame. + /// + /// Both buffers start small and grow to the geometry the scene + /// actually needs. Reserving the whole packed budget up front + /// would ask for more than the storage-binding limit most adapters + /// report and fail the first allocation outright. + /// + /// # Arguments + /// + /// - `&HtmlCanvasElement` - The canvas to draw into. + /// + /// # Returns + /// + /// - `Result` - The ready backend, or the reason the + /// WebGPU path could not be set up. + pub async fn attach(canvas: &HtmlCanvasElement) -> Result { + let gpu: JsValue = navigator_gpu()?; + let adapter: JsValue = request_adapter(&gpu).await?; + let device: JsValue = request_device(&adapter).await?; + let owned: Rc>> = Rc::new(RefCell::new(None)); + let failure: Rc>> = Rc::clone(&owned); + let mut context: Option = None; + let mut scene_buffer: Option = None; + let mut frame_buffer: Option = None; + let mut listener: Option = None; + let built: Result = { + let observed: Result = observe_device(&device, &failure); + async { + let error_listener: JsValue = observed?; + listener = Some(error_listener.clone()); + let format_name: String = preferred_format(&gpu); + let surface: JsValue = configure_context(canvas, &device, &format_name)?; + context = Some(surface.clone()); + let queue: JsValue = attribute(&device, BACKEND_WEBGPU_PROPERTY_QUEUE)?; + let layout: JsValue = packed_layout(&device)?; + let module: JsValue = create_shader_module(&device, raytrace_wgsl()).await?; + let pipeline: JsValue = + create_render_pipeline(&device, &module, &layout, &format_name).await?; + let first: JsValue = + create_storage_buffer(&device, BACKEND_WEBGPU_INITIAL_BUFFER_BYTES).await?; + scene_buffer = Some(first.clone()); + let second: JsValue = + create_storage_buffer(&device, BACKEND_WEBGPU_INITIAL_BUFFER_BYTES).await?; + frame_buffer = Some(second.clone()); + let bind_group: JsValue = bind_group_of(&device, &first, &second, &layout)?; + Ok(WebGpuRayTraceBackend { + context: surface, + device: device.clone(), + queue, + layout, + scene_buffer: first, + frame_buffer: second, + bind_group, + pipeline, + scene_capacity: BACKEND_WEBGPU_INITIAL_BUFFER_BYTES, + frame_capacity: BACKEND_WEBGPU_INITIAL_BUFFER_BYTES, + storage_binding_limit: device_limit( + &device, + BACKEND_WEBGPU_LIMIT_STORAGE_BINDING, + ), + buffer_size_limit: device_limit(&device, BACKEND_WEBGPU_LIMIT_BUFFER_SIZE), + texture_dimension_limit: device_limit(&device, BACKEND_WEBGPU_LIMIT_TEXTURE_2D), + failure: owned, + error_listener: listener.take(), + released: false, + }) + } + .await + }; + match built { + Ok(backend) => Ok(backend), + Err(reason) => { + // A partially built backend registered the listener + // before it could fail, so the listener is removed here + // for the same reason `release` removes it: the device is + // about to be destroyed and must not keep calling into + // a backend that will never exist. + if let Some(registered) = listener.as_ref() { + unregister_error_listener(&device, registered); + } + abandon( + &device, + scene_buffer.as_ref(), + frame_buffer.as_ref(), + context.as_ref(), + ); + Err(reason) + } + } + } + + /// The largest surface edge in pixels this device accepts. + /// + /// The limit counts pixels, not bytes: a surface is bounded per + /// edge, so this is the only number a width or a height is checked + /// against. + /// + /// # Returns + /// + /// - `Option` - The device's per-edge pixel limit, or `None` + /// when the device reports none. + pub fn dimension_limit(&self) -> Option { + match self.get_texture_dimension_limit() { + 0 => None, + limit => Some(limit), + } + } + + /// Validates a requested surface size against the device's edge + /// limit. + /// + /// The canvas is not touched here: the renderer commits its own + /// dimensions only after this succeeds, so a rejected size leaves + /// the previous, still valid, surface intact. + /// + /// # Arguments + /// + /// - `u32` - The requested width in pixels. + /// - `u32` - The requested height in pixels. + /// + /// # Returns + /// + /// - `Result<(), JsValue>` - `Ok` when both edges fit the device. + pub fn resize(&mut self, width: u32, height: u32) -> Result<(), JsValue> { + rejection(self.pending_failure())?; + if width == 0 || height == 0 { + return Err(backend_error( + ERROR_ZERO_DIMENSION, + format!("requested {width}x{height}"), + )); + } + let ceiling: u32 = self.get_texture_dimension_limit(); + if ceiling != 0 && (width > ceiling || height > ceiling) { + return Err(backend_error( + ERROR_GPU_LIMIT, + format!("{width}x{height} exceeds the {ceiling} pixel edge limit"), + )); + } + Ok(()) + } + + /// Grows one packed buffer when a block no longer fits. + /// + /// The device limit is checked before the allocation, so an + /// oversized block is rejected synchronously and the previous, still + /// valid, buffer stays bound. Growth then allocates, rebinds, and + /// only destroys the old buffer once every step that could fail has + /// succeeded — the reverse order would leave the backend with no + /// scene buffer at all if the rebinding failed. + /// + /// The new bind group is created against the persistent layout, not + /// a fresh one: a bind group records the layout it was made with, so + /// a different layout would be rejected by the pipeline at draw + /// time. + /// + /// A failure between allocating the new buffer and swapping it in is + /// transactional in both directions: the new buffer is released, so + /// nothing is left bound to nothing, and the old buffer, capacity, + /// and bind group are all left untouched, so the previous scene + /// still renders and a later call may retry. The error reported is + /// the one the failing step produced, never the outcome of the + /// release that follows it. + /// + /// # Arguments + /// + /// - `bool` - Whether this is the scene buffer rather than the frame + /// buffer. + /// - `u32` - The byte size the block needs. + /// + /// # Returns + /// + /// - `Result<(), JsValue>` - `Ok` once the buffer can hold the + /// block. + fn ensure_capacity(&mut self, scene: bool, bytes: u32) -> Result<(), JsValue> { + let current: u32 = if scene { + self.get_scene_capacity() + } else { + self.get_frame_capacity() + }; + if bytes <= current { + return Ok(()); + } + let ceiling: u32 = self.byte_ceiling(); + if ceiling != 0 && bytes > ceiling { + return Err(backend_error( + ERROR_GPU_LIMIT, + format!("a packed block of {bytes} bytes exceeds the device limit {ceiling}"), + )); + } + if bytes == 0 { + return Err(backend_error( + ERROR_WEBGPU_CALL, + "a packed block of zero bytes cannot back a storage buffer".to_string(), + )); + } + let usage: u32 = BACKEND_WEBGPU_USAGE_STORAGE | BACKEND_WEBGPU_USAGE_COPY_DST; + let buffer_descriptor: Object = descriptor(&[ + ( + BACKEND_WEBGPU_PROPERTY_SIZE, + JsValue::from_f64(bytes as f64), + ), + ( + BACKEND_WEBGPU_PROPERTY_USAGE_FLAGS, + JsValue::from_f64(usage as f64), + ), + ])?; + let device: JsValue = self.get_device(); + let grown: JsValue = call1( + &device, + BACKEND_WEBGPU_METHOD_CREATE_BUFFER, + buffer_descriptor.as_ref(), + )?; + if grown.is_undefined() || grown.is_null() { + return Err(backend_error( + ERROR_WEBGPU_CALL, + "createBuffer returned nothing while growing a packed buffer".to_string(), + )); + } + let layout: JsValue = self.get_layout(); + let frame_buffer: JsValue = self.get_frame_buffer(); + let scene_buffer: JsValue = self.get_scene_buffer(); + let bound: Result = if scene { + bind_group_of(&device, &grown, &frame_buffer, &layout) + } else { + bind_group_of(&device, &scene_buffer, &grown, &layout) + }; + let group: JsValue = match bound { + Ok(built) => built, + Err(reason) => { + // The grown buffer was allocated before the bind group + // was built, and the caller keeps the previous buffer + // bound: the old scene stays valid and a later retry + // allocates again. The new buffer is therefore + // unreachable from here on, so it is destroyed before + // the original error propagates. Its own destruction + // outcome is discarded — a cleanup failure must not + // replace the real one, which is the only thing the + // caller can act on. + let _: Result<(), JsValue> = destroy_buffer(&grown); + return Err(reason); + } + }; + let previous: JsValue = if scene { scene_buffer } else { frame_buffer }; + if scene { + self.set_scene_buffer(grown); + self.set_scene_capacity(bytes); + } else { + self.set_frame_buffer(grown); + self.set_frame_capacity(bytes); + } + self.set_bind_group(group); + // The old buffer is unbound and already replaced, so it can + // only now be destroyed. Destroying it earlier would, if the + // rebinding had failed, leave the backend with neither. + let _: Result<(), JsValue> = destroy_buffer(&previous); + Ok(()) + } + + /// Uploads the packed scene block into its storage buffer. + /// + /// # Arguments + /// + /// - `&[f32]` - The packed scene block. + /// + /// # Returns + /// + /// - `Result<(), JsValue>` - `Ok` once the upload was issued. + pub fn upload_scene(&mut self, packed: &[f32]) -> Result<(), JsValue> { + rejection(self.pending_failure())?; + let bytes: u32 = packed_byte_length(packed.len()); + self.ensure_capacity(true, bytes)?; + let queue: JsValue = self.get_queue(); + let buffer: JsValue = self.get_scene_buffer(); + write_packed(&queue, &buffer, packed) + } + + /// Refills the frame buffer and submits the fullscreen draw. + /// + /// # Arguments + /// + /// - `&[f32]` - The packed frame block. + /// + /// # Returns + /// + /// - `Result<(), JsValue>` - `Ok` once the frame is submitted. + pub fn draw(&mut self, frame: &[f32]) -> Result<(), JsValue> { + rejection(self.pending_failure())?; + let bytes: u32 = packed_byte_length(frame.len()); + self.ensure_capacity(false, bytes)?; + let queue: JsValue = self.get_queue(); + let frame_buffer: JsValue = self.get_frame_buffer(); + write_packed(&queue, &frame_buffer, frame)?; + let device: JsValue = self.get_device(); + let context: JsValue = self.get_context(); + let texture: JsValue = call0(&context, BACKEND_WEBGPU_METHOD_CURRENT_TEXTURE)?; + if texture.is_undefined() || texture.is_null() { + return Err(backend_error( + BACKEND_WEBGPU_ERROR_TEXTURE, + "getCurrentTexture returned nothing".to_string(), + )); + } + // A colour attachment takes a view, not a texture: handing it + // the texture itself invalidates the render pass with nothing + // thrown, which is exactly the silent black frame this backend + // must not produce. + let view: JsValue = call0(&texture, BACKEND_WEBGPU_METHOD_CREATE_VIEW)?; + if view.is_undefined() || view.is_null() { + return Err(backend_error( + BACKEND_WEBGPU_ERROR_VIEW, + "createView returned nothing".to_string(), + )); + } + let attachment: Object = clear_attachment(&view)?; + let attachments: Array = Array::new(); + attachments.push(attachment.as_ref()); + let pass_descriptor: Object = descriptor(&[( + BACKEND_WEBGPU_PROPERTY_COLOR_ATTACHMENTS, + attachments.into(), + )])?; + let encoder: JsValue = call0(&device, BACKEND_WEBGPU_METHOD_CREATE_ENCODER)?; + let pass: JsValue = call1( + &encoder, + BACKEND_WEBGPU_METHOD_BEGIN_PASS, + pass_descriptor.as_ref(), + )?; + let pipeline: JsValue = self.get_pipeline(); + call1(&pass, BACKEND_WEBGPU_METHOD_SET_PIPELINE, &pipeline)?; + let group: JsValue = self.get_bind_group(); + let index: JsValue = JsValue::from_f64(BACKEND_WEBGPU_BIND_GROUP_INDEX as f64); + call2(&pass, BACKEND_WEBGPU_METHOD_SET_BIND_GROUP, &index, &group)?; + let vertex_count: JsValue = JsValue::from_f64(BACKEND_WEBGPU_VERTEX_COUNT as f64); + let instances: JsValue = JsValue::from_f64(BACKEND_WEBGPU_INSTANCE_COUNT as f64); + let first_vertex: JsValue = JsValue::from_f64(BACKEND_WEBGPU_FIRST_VERTEX as f64); + let first_instance: JsValue = JsValue::from_f64(BACKEND_WEBGPU_FIRST_INSTANCE as f64); + call4( + &pass, + BACKEND_WEBGPU_METHOD_DRAW, + &vertex_count, + &instances, + &first_vertex, + &first_instance, + )?; + call0(&pass, BACKEND_WEBGPU_METHOD_END)?; + let command: JsValue = call0(&encoder, BACKEND_WEBGPU_METHOD_FINISH)?; + let batch: Array = Array::new(); + batch.push(command.as_ref()); + call1(&queue, BACKEND_WEBGPU_METHOD_SUBMIT, batch.as_ref())?; + rejection(self.pending_failure()) + } + + /// Destroys every GPU resource the backend created, including its + /// device. + /// + /// The device is destroyed because this backend requested it: it is + /// the backend's own, not a shared one borrowed from a caller, and + /// leaving it alive would pin every buffer, texture, and pipeline + /// the browser had already reclaimed. A second call finds the + /// released flag and destroys nothing twice. + /// + /// The `uncapturederror` listener is removed while the device is + /// still alive: it owns a Rust callback, and destroying the device + /// leaves the device free to stop calling it, but the reference it + /// holds has to be handed back explicitly. The `device.lost` + /// callback is not removed — destroying the device settles that + /// promise, which is exactly what frees its one-shot callback. + /// + /// # Returns + /// + /// - `()` - Cleanup never fails and is never repeated. + pub fn release(&mut self) { + if self.get_released() { + return; + } + self.set_released(true); + let device: JsValue = self.get_device(); + if let Some(listener) = self.get_error_listener() { + unregister_error_listener(&device, &listener); + self.set_error_listener(None); + } + let context: JsValue = self.get_context(); + let _: Result = call0(&context, BACKEND_WEBGPU_METHOD_UNCONFIGURE); + let scene: JsValue = self.get_scene_buffer(); + let _: Result<(), JsValue> = destroy_buffer(&scene); + let frame: JsValue = self.get_frame_buffer(); + let _: Result<(), JsValue> = destroy_buffer(&frame); + destroy_device(&device); + } +} + +impl Drop for WebGpuRayTraceBackend { + /// Releases the device when the backend is dropped without an + /// explicit release. + /// + /// # Returns + /// + /// - `()` - Cleanup never fails. + fn drop(&mut self) { + self.release(); + } +} + +impl WebGpuRayTraceBackend { + /// Reports the failure the browser recorded, if any. + /// + /// A device loss is sticky: it is never drained, because a lost + /// device cannot recover and clearing the record would let a later + /// `resize` or `update_scene` report success on a device whose + /// every subsequent submission is silently dropped. An + /// `uncapturederror` is transient and *is* drained, so the frame + /// that follows the invalid command fails and later frames work + /// again — that is the contract the validation-error path relies + /// on. + /// + /// A concurrent borrow is the frame whose handler is still + /// recording, not a failure: it yields the placeholder rather than + /// panicking, because a lost device must report its reason instead + /// of aborting the page. + /// + /// # Returns + /// + /// - `Option` - The recorded diagnostic, or `None` when the + /// device is healthy. + pub(crate) fn pending_failure(&self) -> Option { + let mut cell: RefMut> = match self.get_failure().try_borrow_mut() { + Ok(borrowed) => borrowed, + Err(_) => return Some(WEBGPU_FAILURE_BUSY.to_string()), + }; + match cell.as_ref() { + Some(recorded) if is_device_lost(recorded) => Some(recorded.clone()), + Some(_) => cell.take(), + None => None, + } + } + + /// The device's largest storage binding and buffer sizes in bytes, + /// as the smaller of the two. + /// + /// A binding may not exceed either limit, and taking one number + /// means the capacity check and the allocation check cannot + /// disagree about which is the binding's ceiling. + /// + /// # Returns + /// + /// - `u32` - The byte ceiling, or zero when the device reports + /// neither limit. + pub(crate) fn byte_ceiling(&self) -> u32 { + let binding: u32 = self.get_storage_binding_limit(); + let buffer: u32 = self.get_buffer_size_limit(); + match (binding, buffer) { + (0, 0) => 0, + (0, size) => size, + (size, 0) => size, + (first, second) => first.min(second), + } + } +} diff --git a/engine/src/raytracing/backend/webgpu/mod.rs b/engine/src/raytracing/backend/webgpu/mod.rs new file mode 100644 index 00000000..3fbb2016 --- /dev/null +++ b/engine/src/raytracing/backend/webgpu/mod.rs @@ -0,0 +1,10 @@ +mod r#const; +mod r#fn; +mod r#impl; +mod r#struct; + +pub use r#struct::*; + +pub(crate) use {r#const::*, r#fn::*}; + +use super::*; diff --git a/engine/src/raytracing/backend/webgpu/struct.rs b/engine/src/raytracing/backend/webgpu/struct.rs new file mode 100644 index 00000000..12e75a8a --- /dev/null +++ b/engine/src/raytracing/backend/webgpu/struct.rs @@ -0,0 +1,137 @@ +use super::*; + +/// The WebGPU backend: a fullscreen triangle over two storage buffers. +/// +/// The device, pipeline, bind group, and both packed buffers are created +/// once. A buffer grows only when a block no longer fits, and a +/// steady-state frame is two `writeBuffer` calls plus one render pass +/// over three vertices — no allocation and no await. +/// +/// Every WebGPU handle is a `JsValue` reached through `Reflect`. The +/// typed `Gpu*` bindings in `web-sys` sit behind a `cfg` the library +/// cannot force on its own consumers, and a `Reflect` call reports a +/// throw as a `Result` the caller must handle, so every failure is +/// propagated instead of being folded into a silent `undefined`. +/// +/// Validation is awaited during construction rather than assumed: the +/// shader module's compilation info and every resource allocation are +/// wrapped in an error scope that is popped and inspected. Wrapping the +/// synchronous calls alone would report success for an invalid pipeline, +/// because WebGPU reports validation asynchronously. +/// +/// The device is requested here and owned here for the backend's whole +/// lifetime. It is destroyed on release: a device this backend created +/// is not a shared resource, and leaving it alive would pin the +/// adapter's resources for the life of the page. +#[derive(Data, Debug)] +pub struct WebGpuRayTraceBackend { + /// The WebGPU canvas rendering context, already configured. + #[get(pub(crate), type(clone))] + #[get_mut(pub(crate))] + #[set(pub(crate))] + pub(crate) context: JsValue, + /// The device every resource is created from, owned by this backend. + #[get(pub(crate), type(clone))] + #[get_mut(pub(crate))] + #[set(pub(crate))] + pub(crate) device: JsValue, + /// The device's queue, used for the packed-buffer writes and the + /// command submission. + #[get(pub(crate), type(clone))] + #[get_mut(pub(crate))] + #[set(pub(crate))] + pub(crate) queue: JsValue, + /// The bind-group layout the persistent bind group was created + /// against. + /// + /// A bind group records the layout it was created with, so a grown + /// buffer needs a new bind group built from the *same* layout the + /// pipeline layout names. Recreating the layout here and passing + /// the new one to `createBindGroup` would produce a group the + /// pipeline rejects at draw time. + #[get(pub(crate), type(clone))] + #[get_mut(pub(crate))] + #[set(pub(crate))] + pub(crate) layout: JsValue, + /// The storage buffer holding the packed scene block. + #[get(pub(crate), type(clone))] + #[get_mut(pub(crate))] + #[set(pub(crate))] + pub(crate) scene_buffer: JsValue, + /// The storage buffer holding the packed frame block. + #[get(pub(crate), type(clone))] + #[get_mut(pub(crate))] + #[set(pub(crate))] + pub(crate) frame_buffer: JsValue, + /// The bind group pairing both buffers with the persistent layout. + #[get(pub(crate), type(clone))] + #[get_mut(pub(crate))] + #[set(pub(crate))] + pub(crate) bind_group: JsValue, + /// The compiled WGSL pipeline, resolved asynchronously at attach. + #[get(pub(crate), type(clone))] + #[get_mut(pub(crate))] + #[set(pub(crate))] + pub(crate) pipeline: JsValue, + /// The byte capacity of the current scene buffer, so a smaller + /// scene reuses it instead of re-allocating. + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] + pub(crate) scene_capacity: u32, + /// The byte capacity of the current frame buffer. + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] + pub(crate) frame_capacity: u32, + /// The device's largest addressable storage binding in bytes, or + /// zero when the device reports none. + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] + pub(crate) storage_binding_limit: u32, + /// The device's largest allocatable buffer in bytes, or zero when + /// the device reports none. + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] + pub(crate) buffer_size_limit: u32, + /// The device's largest texture edge in pixels, or zero when the + /// device reports none. + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] + pub(crate) texture_dimension_limit: u32, + /// The failure the browser reported since the last frame drained + /// it, shared by the lost and uncaptured-error handlers. + #[get(pub(crate))] + #[get_mut(pub(crate))] + #[set(pub(crate))] + pub(crate) failure: Rc>>, + /// The registered `uncapturederror` listener, removed on release. + /// + /// The listener owns a Rust callback and the device holds a strong + /// reference to it, so the handle is kept here rather than dropped: + /// releasing the backend has to hand the exact same function + /// reference back to `removeEventListener`, and a callback that were + /// dropped first would be invoked by the next validation error on a + /// device this backend no longer owns. + /// + /// The `device.lost` callback is *not* stored. That promise settles + /// exactly once — and destroying the device settles it — so it is + /// built as a one-shot callback owned by JavaScript + /// (`Closure::once_into_js`), which frees its captured state the + /// moment the browser calls it. Holding it in this struct and + /// clearing it before `destroy` is what produced `closure invoked + /// recursively or after being dropped`. + #[get(pub(crate), type(clone))] + #[get_mut(pub(crate))] + #[set(pub(crate))] + pub(crate) error_listener: Option, + /// Whether release already ran, so a second release destroys + /// nothing twice. + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] + pub(crate) released: bool, +} diff --git a/engine/src/raytracing/const.rs b/engine/src/raytracing/const.rs deleted file mode 100644 index be48c030..00000000 --- a/engine/src/raytracing/const.rs +++ /dev/null @@ -1,17 +0,0 @@ -/// The default maximum number of bounces evaluated by -/// [`RayTraceScene::trace`](crate::RayTraceScene::trace) before returning. Higher values yield more -/// accurate reflections at increased computational cost. -pub(crate) const RAYTRACE_DEFAULT_MAX_BOUNCES: u32 = 4; - -/// The minimum ray parameter `t` accepted as a valid intersection. Hits -/// closer than this are treated as self-intersection artifacts and skipped. -pub(crate) const RAYTRACE_DEFAULT_T_MIN: f64 = 0.001; - -/// The maximum ray parameter `t` evaluated before a ray is considered to -/// have escaped the scene. Pairs with `RAYTRACE_DEFAULT_T_MIN`. -pub(crate) const RAYTRACE_DEFAULT_T_MAX: f64 = 1000.0; - -/// The Moller-Trumbore determinant magnitude below which a ray is treated as -/// parallel to the triangle plane and therefore unable to hit it. Pairs with -/// the shared [`EPSILON`](crate::EPSILON) tolerance. -pub(crate) const RAYTRACE_TRIANGLE_EPSILON: f64 = 1e-8; diff --git a/engine/src/raytracing/fn.rs b/engine/src/raytracing/fn.rs deleted file mode 100644 index 60af11da..00000000 --- a/engine/src/raytracing/fn.rs +++ /dev/null @@ -1,390 +0,0 @@ -use super::*; - -/// Returns the component-wise minimum of two vectors. -/// -/// # Arguments -/// -/// - `Vector3D` - The first vector. -/// - `Vector3D` - The second vector. -/// -/// # Returns -/// -/// - `Vector3D` - The component-wise minimum. -fn vector3d_min(a: Vector3D, b: Vector3D) -> Vector3D { - Vector3D::new( - a.get_x().min(b.get_x()), - a.get_y().min(b.get_y()), - a.get_z().min(b.get_z()), - ) -} - -/// Returns the component-wise maximum of two vectors. -/// -/// # Arguments -/// -/// - `Vector3D` - The first vector. -/// - `Vector3D` - The second vector. -/// -/// # Returns -/// -/// - `Vector3D` - The component-wise maximum. -fn vector3d_max(a: Vector3D, b: Vector3D) -> Vector3D { - Vector3D::new( - a.get_x().max(b.get_x()), - a.get_y().max(b.get_y()), - a.get_z().max(b.get_z()), - ) -} - -/// Returns the inclusive parameter interval of a single AABB slab along one -/// ray axis, or `None` when the ray runs parallel to the slab outside it. -/// -/// A parallel axis is not rejected outright: the interval becomes -/// `(-inf, +inf)` when the origin lies inside the slab and the caller keeps -/// only the two axes that actually constrain the ray. -/// -/// # Arguments -/// -/// - `f64` - The ray origin component on this axis. -/// - `f64` - The ray direction component on this axis. -/// - `f64` - The slab lower bound. -/// - `f64` - The slab upper bound. -/// -/// # Returns -/// -/// - `Option<(f64, f64)>` - The `(enter, exit)` parameters, or `None` when -/// the ray is parallel to the slab and outside it. -fn slab_interval( - origin_component: f64, - dir_component: f64, - lower: f64, - upper: f64, -) -> Option<(f64, f64)> { - if dir_component.abs() < RAYTRACE_TRIANGLE_EPSILON { - if origin_component < lower || origin_component > upper { - return None; - } - return Some((f64::NEG_INFINITY, f64::INFINITY)); - } - let inverse: f64 = 1.0 / dir_component; - let t0: f64 = (lower - origin_component) * inverse; - let t1: f64 = (upper - origin_component) * inverse; - Some((t0.min(t1), t0.max(t1))) -} - -/// Reports whether the ray segment `t_min`..=`t_max` can touch the AABB -/// spanning `lower` to `upper`. -/// -/// This is a conservative broad-phase reject: it never discards an occluder -/// whose exact test could report a hit inside the requested range, because -/// the AABB fully contains the occluder and every exact hit lies inside the -/// AABB. -/// -/// # Arguments -/// -/// - `Vector3D` - The ray origin. -/// - `Vector3D` - The ray direction. -/// - `Vector3D` - The AABB minimum corner. -/// - `Vector3D` - The AABB maximum corner. -/// - `f64` - The minimum ray parameter considered a valid hit. -/// - `f64` - The maximum ray parameter considered a valid hit. -/// -/// # Returns -/// -/// - `bool` - `true` when the segment may touch the AABB. -fn ray_segment_overlaps_aabb( - origin: Vector3D, - dir: Vector3D, - lower: Vector3D, - upper: Vector3D, - t_min: f64, - t_max: f64, -) -> bool { - let x: Option<(f64, f64)> = - slab_interval(origin.get_x(), dir.get_x(), lower.get_x(), upper.get_x()); - let y: Option<(f64, f64)> = - slab_interval(origin.get_y(), dir.get_y(), lower.get_y(), upper.get_y()); - let z: Option<(f64, f64)> = - slab_interval(origin.get_z(), dir.get_z(), lower.get_z(), upper.get_z()); - match (x, y, z) { - (Some((x0, x1)), Some((y0, y1)), Some((z0, z1))) => { - let t_enter: f64 = x0.max(y0).max(z0); - let t_exit: f64 = x1.min(y1).min(z1); - t_enter <= t_exit && t_exit >= t_min && t_enter <= t_max - } - _ => false, - } -} - -/// Intersects a ray with the triangle `v0`, `v1`, `v2` using the -/// Moller-Trumbore algorithm. -/// -/// The test is two-sided: a hit on either face counts, and the returned -/// normal is flipped when needed so that it always faces against the ray -/// direction, making back-face hits usable for shading. A ray parallel to -/// the triangle plane yields `None`. No `t_min`/`t_max` filtering is -/// applied here; use [`Ray::intersect_triangle`] for the range-clamped -/// variant. -/// -/// # Arguments -/// -/// - `Vector3D` - The ray origin. -/// - `Vector3D` - The ray direction (expected to be unit length). -/// - `Vector3D` - The first triangle vertex. -/// - `Vector3D` - The second triangle vertex. -/// - `Vector3D` - The third triangle vertex. -/// -/// # Returns -/// -/// - `Option<(f64, Vector3D)>` - The hit distance along the ray and the -/// unit surface normal oriented against the ray direction, or `None` on -/// miss. -pub fn intersect_triangle( - origin: Vector3D, - dir: Vector3D, - v0: Vector3D, - v1: Vector3D, - v2: Vector3D, -) -> Option<(f64, Vector3D)> { - let edge1: Vector3D = v1 - v0; - let edge2: Vector3D = v2 - v0; - let pvec: Vector3D = dir.cross(edge2); - let det: f64 = edge1.dot(pvec); - if det.abs() < RAYTRACE_TRIANGLE_EPSILON { - return None; - } - let inverse_det: f64 = 1.0 / det; - let tvec: Vector3D = origin - v0; - let u: f64 = tvec.dot(pvec) * inverse_det; - if u < 0.0 || u > 1.0 { - return None; - } - let qvec: Vector3D = tvec.cross(edge1); - let v: f64 = dir.dot(qvec) * inverse_det; - if v < 0.0 || u + v > 1.0 { - return None; - } - let t: f64 = edge2.dot(qvec) * inverse_det; - let geometric: Vector3D = edge1.cross(edge2).normalized(); - let normal: Vector3D = if geometric.dot(dir) > 0.0 { - -geometric - } else { - geometric - }; - Some((t, normal)) -} - -/// Returns the AABB extents `(min, max)` of an [`Occluder`]. -/// -/// For AABB occluders this is `(center, extent)`. For sphere occluders -/// the bounding box is computed from the center and the `.x` component of -/// `extent` (the sphere radius). For triangle occluders the bounding box -/// is the component-wise span of the three vertices. -/// -/// # Arguments -/// -/// - `&Occluder` - The occluder to bound. -/// -/// # Returns -/// -/// - `(Vector3D, Vector3D)` - The `(min, max)` corners of the AABB. -fn occluder_aabb_extents(occluder: &Occluder) -> (Vector3D, Vector3D) { - let (mn, mx): (Vector3D, Vector3D) = match occluder.get_kind() { - OccluderKind::Aabb => (occluder.get_center(), occluder.get_extent()), - OccluderKind::Sphere => { - let center: Vector3D = occluder.get_center(); - let radius: f64 = occluder.get_extent().get_x(); - let r: Vector3D = Vector3D::new(radius, radius, radius); - (center - r, center + r) - } - OccluderKind::Triangle => { - let [v0, v1, v2]: [Vector3D; 3] = occluder.get_vertices(); - let lo: Vector3D = vector3d_min(v0, vector3d_min(v1, v2)); - let hi: Vector3D = vector3d_max(v0, vector3d_max(v1, v2)); - (lo, hi) - } - }; - (vector3d_min(mn, mx), vector3d_max(mn, mx)) -} - -/// Flattens every occluder into `(center, radius)` sphere tuples used by -/// [`soft_shadow_factor`]. -/// -/// For sphere occluders the tuple is `(center, radius)`. For AABB -/// occluders a conservative bounding sphere is computed from the AABB. -/// -/// # Arguments -/// -/// - `&[Occluder]` - The occluders to flatten into bounding spheres. -/// -/// # Returns -/// -/// - `Vec<(Vector3D, f64)>` - The `(center, radius)` sphere of each occluder. -pub(crate) fn collect_occluder_points(occluders: &[Occluder]) -> Vec<(Vector3D, f64)> { - let mut out: Vec<(Vector3D, f64)> = Vec::new(); - for occ in occluders.iter() { - let (mn, mx): (Vector3D, Vector3D) = occluder_aabb_extents(occ); - let cx: f64 = (mn.get_x() + mx.get_x()) * 0.5; - let cy: f64 = (mn.get_y() + mx.get_y()) * 0.5; - let cz: f64 = (mn.get_z() + mx.get_z()) * 0.5; - let ex: f64 = (mx.get_x() - mn.get_x()) * 0.5; - let ey: f64 = (mx.get_y() - mn.get_y()) * 0.5; - let ez: f64 = (mx.get_z() - mn.get_z()) * 0.5; - let r: f64 = (ex * ex + ey * ey + ez * ez).sqrt(); - out.push((Vector3D::new(cx, cy, cz), r)); - } - out -} - -/// Finds the closest intersection between a ray and a list of occluders -/// without touching any [`Material`]. -/// -/// Returns the winning occluder's index alongside the hit data so callers -/// can borrow the material directly from the occluder list instead of -/// cloning it per candidate. The tie-breaking rule matches the historical -/// behavior: the first occluder achieving the minimum `t` wins. -/// -/// Every occluder is first run through a broad-phase AABB slab test -/// against the ray's `t_min`..=`t_max` segment; occluders whose bounds the -/// segment provably cannot touch are skipped without any exact math. The -/// test is conservative, so it only removes candidates that could not have -/// produced a hit, leaving the returned hit identical to a full scan. -/// -/// # Arguments -/// -/// - `&Ray` - The ray to test. -/// - `&[Occluder]` - The occluders to test against. -/// -/// # Returns -/// -/// - `Option<(usize, f64, Vector3D, Vector3D)>` - The occluder index, the -/// hit distance `t`, the hit position, and the surface normal, or `None` -/// if the ray misses. -pub(crate) fn closest_hit_indexed( - ray: &Ray, - occluders: &[Occluder], -) -> Option<(usize, f64, Vector3D, Vector3D)> { - let origin: Vector3D = ray.get_origin(); - let dir: Vector3D = ray.get_direction(); - let t_min: f64 = ray.get_t_min(); - let t_max: f64 = ray.get_t_max(); - let mut best: Option<(usize, f64, Vector3D, Vector3D)> = None; - for (index, occ) in occluders.iter().enumerate() { - let (bound_min, bound_max): (Vector3D, Vector3D) = occluder_aabb_extents(occ); - if !ray_segment_overlaps_aabb(origin, dir, bound_min, bound_max, t_min, t_max) { - continue; - } - let candidate: Option<(f64, Vector3D)> = match occ.get_kind() { - OccluderKind::Sphere => { - let center: Vector3D = occ.get_center(); - let radius: f64 = occ.get_extent().get_x(); - match ray_sphere_intersect(origin, dir, center, radius) { - Some((t, n)) if t >= t_min && t <= t_max => Some((t, n)), - _ => None, - } - } - OccluderKind::Aabb => match ray_aabb_intersect(origin, dir, bound_min, bound_max) { - Some((t_near, _t_far, n)) if t_near >= t_min && t_near <= t_max => { - Some((t_near, n)) - } - _ => None, - }, - OccluderKind::Triangle => { - let [v0, v1, v2]: [Vector3D; 3] = occ.get_vertices(); - ray.intersect_triangle(v0, v1, v2) - } - }; - if let Some((t, n)) = candidate { - let keep_previous: bool = matches!(&best, Some(previous) if previous.1 <= t); - if !keep_previous { - let hit_pos: Vector3D = origin + dir.scaled(t); - best = Some((index, t, hit_pos, n)); - } - } - } - best -} - -/// Builds a reflected ray bouncing off a surface point with the given -/// normal. -/// -/// # Arguments -/// -/// - `&Ray` - The incoming ray. -/// - `Vector3D` - The world-space hit position. -/// - `Vector3D` - The outward unit normal at the hit point. -/// -/// # Returns -/// -/// - `Ray` - A new ray originating at the hit point with the reflected -/// direction, `t_min` reset to `RAYTRACE_DEFAULT_T_MIN`, `t_max` set to -/// `RAYTRACE_DEFAULT_T_MAX`, and `depth` incremented by one. -fn bounce_ray(ray: &Ray, position: Vector3D, normal: Vector3D) -> Ray { - let dir: Vector3D = ray.get_direction(); - let dot: f64 = dir.dot(normal); - let reflected_dir: Vector3D = dir - normal.scaled(2.0 * dot); - Ray { - origin: position, - direction: reflected_dir, - t_min: RAYTRACE_DEFAULT_T_MIN, - t_max: RAYTRACE_DEFAULT_T_MAX, - depth: ray.get_depth() + 1, - } -} - -/// Iteratively traces a ray against `occluders` using precomputed shadow -/// bounding spheres, performing no heap allocation per bounce. -/// -/// Color contributions are accumulated with a specular throughput: each -/// bounce multiplies the throughput by the hit material's specular -/// intensity, and a miss adds the ambient color scaled by the current -/// throughput. The bounce loop stops when the ray misses, when `depth` -/// reaches `max_bounces`, or when the hit material's specular intensity is -/// not greater than [`EPSILON`], matching the behavior of the historical -/// recursive formulation. -/// -/// # Arguments -/// -/// - `Ray` - The ray to trace. -/// - `&[Occluder]` - All occluding surfaces in the scene. -/// - `&[(Vector3D, f64)]` - Precomputed `(center, radius)` shadow bounding -/// spheres, one per occluder. -/// - `&LightingUniforms` - Lighting parameters used during shading. -/// - `u32` - The maximum number of bounces allowed for this ray. -/// -/// # Returns -/// -/// - `Vector3D` - The final traced color. -pub(crate) fn trace_bounces( - ray: Ray, - occluders: &[Occluder], - shadow_points: &[(Vector3D, f64)], - lights: &LightingUniforms, - max_bounces: u32, -) -> Vector3D { - let ambient: Vector3D = lights.get_ambient(); - let mut color: Vector3D = Vector3D::zero(); - let mut throughput: f64 = 1.0; - let mut current: Ray = ray; - loop { - let (index, _t, position, normal): (usize, f64, Vector3D, Vector3D) = - match closest_hit_indexed(¤t, occluders) { - None => { - color += ambient.scaled(throughput); - break; - } - Some(hit) => hit, - }; - let material: &Material = occluders[index].get_material(); - color += lights - .shade(position, normal, material, shadow_points) - .scaled(throughput); - let spec: f64 = material.get_specular(); - if current.get_depth() >= max_bounces || spec <= EPSILON { - break; - } - throughput *= spec; - current = bounce_ray(¤t, position, normal); - } - color -} diff --git a/engine/src/raytracing/mod.rs b/engine/src/raytracing/mod.rs index d2224625..77693b9e 100644 --- a/engine/src/raytracing/mod.rs +++ b/engine/src/raytracing/mod.rs @@ -1,12 +1,7 @@ -mod r#const; -mod r#enum; -mod r#fn; -mod r#impl; -mod r#struct; +mod r#backend; +mod r#pipeline; +mod r#scene; -pub use {r#enum::*, r#fn::intersect_triangle, r#struct::*}; - -pub(crate) use r#const::*; -pub(crate) use r#fn::{closest_hit_indexed, collect_occluder_points, trace_bounces}; +pub use {r#backend::*, r#pipeline::*, r#scene::*}; use super::*; diff --git a/engine/src/raytracing/pipeline/data/const.rs b/engine/src/raytracing/pipeline/data/const.rs new file mode 100644 index 00000000..5ac6fb8c --- /dev/null +++ b/engine/src/raytracing/pipeline/data/const.rs @@ -0,0 +1,123 @@ +/// The inclusive lower bound accepted by +/// [`RayTraceSettings::validate`] for `samples_per_axis`. +pub(crate) const RAYTRACE_SAMPLES_PER_AXIS_MIN: u32 = 1; + +/// The inclusive upper bound accepted by +/// [`RayTraceSettings::validate`] for `samples_per_axis`. +pub(crate) const RAYTRACE_SAMPLES_PER_AXIS_MAX: u32 = 4; + +/// The inclusive lower bound accepted by +/// [`RayTraceSettings::validate`] for `max_bounces`. +pub(crate) const RAYTRACE_MAX_BOUNCES_MIN: u32 = 0; + +/// The inclusive upper bound accepted by +/// [`RayTraceSettings::validate`] for `max_bounces`. +pub(crate) const RAYTRACE_MAX_BOUNCES_MAX: u32 = 16; + +/// The default `samples_per_axis` used by [`RayTraceSettings::new_default`]. +pub(crate) const RAYTRACE_DEFAULT_SAMPLES_PER_AXIS: u32 = 1; + +/// The default `max_bounces`, mirroring +/// [`RAYTRACE_DEFAULT_MAX_BOUNCES`](super::super::RAYTRACE_DEFAULT_MAX_BOUNCES). +pub(crate) const RAYTRACE_DEFAULT_MAX_BOUNCES_LIMIT: u32 = 4; + +/// The number of `vec4` slots one packed scene node occupies. +pub(crate) const RAYTRACE_PACKED_NODE_VEC4S: usize = 3; + +/// The number of `vec4` slots one packed primitive record occupies. +pub(crate) const RAYTRACE_PACKED_PRIMITIVE_VEC4S: usize = 8; + +/// The number of `vec4` slots in the packed scene header. +pub(crate) const RAYTRACE_PACKED_SCENE_HEADER_VEC4S: usize = 1; + +/// The number of `vec4` slots in the packed frame header. +pub(crate) const RAYTRACE_PACKED_FRAME_HEADER_VEC4S: usize = 8; + +/// The number of `vec4` slots one packed light record occupies. +pub(crate) const RAYTRACE_PACKED_LIGHT_VEC4S: usize = 4; + +/// The gamma exponent applied to the clamped linear color, applied +/// identically by the CPU renderer and by both shader backends. +pub(crate) const RAYTRACE_GAMMA: f64 = 2.2; + +/// The largest finite `f32` magnitude, as an `f64` so the comparison needs +/// no narrowing. A component beyond this has no `f32` representation at all. +pub(crate) const RAYTRACE_MAX_F32_MAGNITUDE: f64 = f32::MAX as f64; + +/// The largest integer a `f32` represents exactly. Packed indices above +/// this bound would be silently truncated by the shaders. +pub(crate) const RAYTRACE_MAX_EXACT_F32_INDEX: usize = 1 << 24; + +/// The largest number of packed `f32` values a single GPU buffer or data +/// texture may address, chosen as the floor of the WebGPU +/// `maxStorageBufferBindingSize` default and of the WebGL2 +/// `MAX_TEXTURE_SIZE` bound on the packed-data texture. +pub(crate) const RAYTRACE_MAX_PACKED_FLOATS: usize = 134_217_728; + +/// Error message returned when `samples_per_axis` is out of range. +pub(crate) const RAYTRACE_ERROR_SAMPLES_PER_AXIS: &str = + "RayTraceSettings::validate: samples_per_axis must be within 1..=4"; + +/// Error message returned when `max_bounces` is out of range. +pub(crate) const RAYTRACE_ERROR_MAX_BOUNCES: &str = + "RayTraceSettings::validate: max_bounces must be within 0..=16"; + +/// Error message returned when a render dimension is zero. +pub(crate) const RAYTRACE_ERROR_ZERO_DIMENSION: &str = + "render_raytrace_rgba: width and height must both be greater than zero"; + +/// Error message returned when a render dimension overflows the packed +/// buffer limit. +pub(crate) const RAYTRACE_ERROR_DIMENSION_OVERFLOW: &str = + "render_raytrace_rgba: width * height exceeds the packed buffer limit"; + +/// Error message returned when the packed frame would exceed the GPU data +/// size limit. +pub(crate) const RAYTRACE_ERROR_FRAME_OVERFLOW: &str = + "pack_raytrace_frame: packed frame exceeds the GPU data limit"; + +/// Error message returned when the packed scene would exceed the GPU data +/// size limit. +pub(crate) const RAYTRACE_ERROR_SCENE_OVERFLOW: &str = + "RayTraceScene::encode_gpu: packed scene exceeds the GPU data limit"; + +/// Error message returned when a geometry component is not finite. +pub(crate) const RAYTRACE_ERROR_NON_FINITE: &str = + "RayTraceScene::encode_gpu: geometry contains a non-finite component"; + +/// Error message returned when a geometry component overflows `f32`. +pub(crate) const RAYTRACE_ERROR_F32_RANGE: &str = + "RayTraceScene::encode_gpu: geometry component is not representable as f32"; + +/// Error message returned when a primitive index does not fit in `f32`. +pub(crate) const RAYTRACE_ERROR_INDEX_RANGE: &str = + "RayTraceScene::encode_gpu: primitive index is not representable as f32"; + +/// Error message returned when a camera component is not finite. +pub(crate) const RAYTRACE_ERROR_CAMERA_NON_FINITE: &str = + "pack_raytrace_frame: camera contains a non-finite component"; + +/// Error message returned when the camera basis cannot be built. +pub(crate) const RAYTRACE_ERROR_CAMERA_BASIS: &str = + "pack_raytrace_frame: camera basis is degenerate"; + +/// Error message returned when a light or ambient component is not finite. +pub(crate) const RAYTRACE_ERROR_LIGHT_NON_FINITE: &str = + "pack_raytrace_frame: lights contain a non-finite component"; + +/// Error message returned when a light component overflows `f32`. +pub(crate) const RAYTRACE_ERROR_LIGHT_F32_RANGE: &str = + "pack_raytrace_frame: light component is not representable as f32"; + +/// Error message returned when the camera field of view is outside the +/// open interval `(0, PI)`. +pub(crate) const RAYTRACE_ERROR_CAMERA_FOV: &str = + "pack_raytrace_frame: fov must lie strictly within 0 < fov < PI"; + +/// Error message returned when the camera depth range is invalid. +pub(crate) const RAYTRACE_ERROR_CAMERA_RANGE: &str = + "pack_raytrace_frame: depth range must satisfy 0 <= near < far"; + +/// Error message returned when a camera range component overflows `f32`. +pub(crate) const RAYTRACE_ERROR_CAMERA_F32_RANGE: &str = + "pack_raytrace_frame: camera range is not representable as f32"; diff --git a/engine/src/raytracing/pipeline/data/fn.rs b/engine/src/raytracing/pipeline/data/fn.rs new file mode 100644 index 00000000..d387fd00 --- /dev/null +++ b/engine/src/raytracing/pipeline/data/fn.rs @@ -0,0 +1,840 @@ +use super::*; + +/// Converts one `f64` component into a packed `f32`, rounding to nearest. +/// +/// Rejects non-finite input and values whose magnitude exceeds the finite +/// `f32` range instead of silently truncating. Round-to-nearest is correct +/// for positions, colours and material scalars, where a half-ulp +/// difference is imperceptible. +/// +/// # Arguments +/// +/// - `f64` - The value to narrow. +/// - `&str` - The error message for a non-finite value. +/// - `&str` - The error message for a value outside the `f32` range. +/// +/// # Returns +/// +/// - `Result` - The narrowed value, or the applicable error. +pub fn pack_finite(value: f64, non_finite: &str, out_of_range: &str) -> Result { + if !value.is_finite() { + return Err(non_finite.to_string()); + } + let narrowed: f32 = value as f32; + if !narrowed.is_finite() { + return Err(out_of_range.to_string()); + } + Ok(narrowed) +} + +/// Returns the next representable `f32` below `value`. +/// +/// The step is one unit in the last place in the direction of decreasing +/// magnitude, which is what a directed rounding needs. Signed zero is +/// handled so the function is total: `-0.0` steps to the smallest negative +/// subnormal and `+0.0` to the smallest positive one. +/// +/// # Arguments +/// +/// - `f32` - The value to step down from. +/// +/// # Returns +/// +/// - `f32` - The next lower `f32`, or `value` itself at the negative +/// infinity boundary where no lower value exists. +pub(crate) fn next_down(value: f32) -> f32 { + if value.is_nan() || value == f32::NEG_INFINITY { + return value; + } + if value > 0.0 { + return f32::from_bits(value.to_bits() - 1); + } + if value == 0.0 { + return -f32::from_bits(1); + } + f32::from_bits(value.to_bits() + 1) +} + +/// Returns the next representable `f32` above `value`, the mirror of +/// [`next_down`]. +/// +/// # Arguments +/// +/// - `f32` - The value to step up from. +/// +/// # Returns +/// +/// - `f32` - The next higher `f32`, or `value` itself at the positive +/// infinity boundary where no higher value exists. +pub(crate) fn next_up(value: f32) -> f32 { + if value.is_nan() || value == f32::INFINITY { + return value; + } + if value >= 0.0 { + return f32::from_bits(value.to_bits() + 1); + } + if value == 0.0 { + return f32::from_bits(1); + } + f32::from_bits(value.to_bits() - 1) +} + +/// Converts one BVH **lower** bound to `f32`, rounding toward negative +/// infinity. +/// +/// A plain `as f32` rounds to nearest, so a lower bound can be nudged +/// *inward*; the packed node then no longer contains geometry the CPU +/// hierarchy considers inside it, and the shader would miss a hit the CPU +/// finds. Snapping outward by one ulp whenever the conversion was inexact +/// makes the packed node a conservative superset of the source node, which +/// is the only safe direction for a bounding volume. +/// +/// # Arguments +/// +/// - `f64` - The source bound. +/// +/// # Returns +/// +/// - `Result` - A representable `f32` no greater than the +/// source. +pub(crate) fn pack_lower_bound(value: f64) -> Result { + let narrowed: f32 = pack_finite(value, RAYTRACE_ERROR_NON_FINITE, RAYTRACE_ERROR_F32_RANGE)?; + if (narrowed as f64) > value { + // Round-to-nearest landed above the source, so step one ulp down. + return Ok(next_down(narrowed)); + } + Ok(narrowed) +} + +/// Converts one BVH **upper** bound to `f32`, rounding toward positive +/// infinity. The mirror of [`pack_lower_bound`]. +/// +/// # Arguments +/// +/// - `f64` - The source bound. +/// +/// # Returns +/// +/// - `Result` - A representable `f32` no less than the source. +pub(crate) fn pack_upper_bound(value: f64) -> Result { + let narrowed: f32 = pack_finite(value, RAYTRACE_ERROR_NON_FINITE, RAYTRACE_ERROR_F32_RANGE)?; + if (narrowed as f64) < value { + // Round-to-nearest landed below the source, so step one ulp up. + return Ok(next_up(narrowed)); + } + Ok(narrowed) +} + +/// Converts a vector to three `f32` components, rounding to nearest. +/// +/// # Arguments +/// +/// - `Vector3D` - The vector to pack. +/// - `&str` - The error message used for a non-finite component. +/// - `&str` - The error message used for an `f32`-overflowing component. +/// +/// # Returns +/// +/// - `Result<[f32; 3], String>` - The packed components, or the first +/// rejection reason. +pub fn pack_vector( + vector: Vector3D, + non_finite: &str, + out_of_range: &str, +) -> Result<[f32; 3], String> { + let x: f32 = pack_finite(vector.get_x(), non_finite, out_of_range)?; + let y: f32 = pack_finite(vector.get_y(), non_finite, out_of_range)?; + let z: f32 = pack_finite(vector.get_z(), non_finite, out_of_range)?; + Ok([x, y, z]) +} + +/// Converts a vector to three `f32` components, rounding every component +/// toward negative infinity. +/// +/// # Arguments +/// +/// - `Vector3D` - The vector to narrow. +/// - `&str` - The error message for a non-finite component. +/// - `&str` - The error message for an out-of-range component. +/// +/// # Returns +/// +/// - `Result<[f32; 3], String>` - The narrowed components, or the first +/// applicable error. +pub fn pack_vector_lower( + vector: Vector3D, + non_finite: &str, + out_of_range: &str, +) -> Result<[f32; 3], String> { + if !vector.get_x().is_finite() || !vector.get_y().is_finite() || !vector.get_z().is_finite() { + return Err(non_finite.to_string()); + } + let magnitude: f64 = vector + .get_x() + .abs() + .max(vector.get_y().abs()) + .max(vector.get_z().abs()); + if magnitude > RAYTRACE_MAX_F32_MAGNITUDE { + return Err(out_of_range.to_string()); + } + Ok([ + pack_lower_bound(vector.get_x())?, + pack_lower_bound(vector.get_y())?, + pack_lower_bound(vector.get_z())?, + ]) +} + +/// Converts a vector to three `f32` components, rounding every component +/// toward positive infinity. +/// +/// # Arguments +/// +/// - `Vector3D` - The vector to narrow. +/// - `&str` - The error message for a non-finite component. +/// - `&str` - The error message for an out-of-range component. +/// +/// # Returns +/// +/// - `Result<[f32; 3], String>` - The narrowed components, or the first +/// applicable error. +pub fn pack_vector_upper( + vector: Vector3D, + non_finite: &str, + out_of_range: &str, +) -> Result<[f32; 3], String> { + if !vector.get_x().is_finite() || !vector.get_y().is_finite() || !vector.get_z().is_finite() { + return Err(non_finite.to_string()); + } + let magnitude: f64 = vector + .get_x() + .abs() + .max(vector.get_y().abs()) + .max(vector.get_z().abs()); + if magnitude > RAYTRACE_MAX_F32_MAGNITUDE { + return Err(out_of_range.to_string()); + } + Ok([ + pack_upper_bound(vector.get_x())?, + pack_upper_bound(vector.get_y())?, + pack_upper_bound(vector.get_z())?, + ]) +} + +/// Appends four `f32` values to `out`, reusing its capacity when it +/// already covers the write. +/// +/// # Arguments +/// +/// - `&mut Vec` - The destination, which keeps its capacity. +/// - `f32` - The x component. +/// - `f32` - The y component. +/// - `f32` - The z component. +/// - `f32` - The w component. +pub fn push_vec4(out: &mut Vec, x: f32, y: f32, z: f32, w: f32) { + out.push(x); + out.push(y); + out.push(z); + out.push(w); +} + +/// Converts a `usize` node or primitive index into an exactly +/// representable `f32`. +/// +/// `f32` represents integers exactly only up to `2^24`; above that a +/// plain `as f32` truncates, which would make a shader read the wrong node +/// or primitive rather than failing loudly. +/// +/// # Arguments +/// +/// - `usize` - The index to convert. +/// +/// # Returns +/// +/// - `Result` - The exactly representable index, or an error +/// once the index passes the `2^24` exact range. +pub fn pack_index(index: usize) -> Result { + if index > RAYTRACE_MAX_EXACT_F32_INDEX { + return Err(RAYTRACE_ERROR_INDEX_RANGE.to_string()); + } + Ok(index as f32) +} + +/// Returns the `f32` discriminator both shaders switch on for an +/// [`OccluderKind`]. +/// +/// # Arguments +/// +/// - `OccluderKind` - The source shape. +/// +/// # Returns +/// +/// - `f32` - The shader discriminator: sphere `0.0`, AABB `1.0`, triangle +/// `2.0`. +pub fn packed_shape_code(kind: OccluderKind) -> f32 { + match kind { + OccluderKind::Sphere => 0.0, + OccluderKind::Aabb => 1.0, + OccluderKind::Triangle => 2.0, + } +} + +/// Returns the `f32` discriminator both shaders switch on for a +/// [`MaterialKind`]. +/// +/// # Arguments +/// +/// - `MaterialKind` - The source material. +/// +/// # Returns +/// +/// - `f32` - The shader discriminator: Lambert `0.0`, Phong `1.0`, PBR +/// `2.0`. +pub fn packed_material_code(kind: MaterialKind) -> f32 { + match kind { + MaterialKind::Lambert => 0.0, + MaterialKind::Phong => 1.0, + MaterialKind::Pbr => 2.0, + } +} + +/// Returns the `f32` discriminator both shaders switch on for a +/// [`LightType`]. +/// +/// # Arguments +/// +/// - `LightType` - The source light. +/// +/// # Returns +/// +/// - `f32` - The shader discriminator: directional `0.0`, point `1.0`, spot +/// `2.0`. +pub fn packed_light_code(kind: LightType) -> f32 { + match kind { + LightType::Directional => 0.0, + LightType::Point => 1.0, + LightType::Spot => 2.0, + } +} + +/// Returns half the `f32` unit in the last place at `probe`. +/// +/// This is the worst-case magnitude by which a round-to-nearest `f64` to +/// `f32` conversion can move a value, and is what a packed sphere radius is +/// widened by. +/// +/// # Arguments +/// +/// - `f32` - A probe with the magnitude of interest. +/// +/// # Returns +/// +/// - `f64` - Half an ulp at `probe`, never zero. +pub(crate) fn half_ulp(probe: f32) -> f64 { + let up: f64 = next_up(probe) as f64; + let down: f64 = next_down(probe) as f64; + let span: f64 = (up - down).abs() * 0.5; + if span.is_finite() && span > 0.0 { + return span; + } + f32::from_bits(1) as f64 +} + +/// Returns the packed radius of a sphere whose centre is packed toward +/// negative infinity. +/// +/// The packed centre is never above the source, so it can sit up to half an +/// ulp below it on each axis. The radius absorbs the worst-case corner +/// displacement of that drift, `sqrt(3)` half-ulp, and is then rounded up so +/// the packed sphere still contains every point of the source sphere. +/// +/// # Arguments +/// +/// - `Vector3D` - The sphere centre, sampled for its ulp scale. +/// - `f64` - The source radius. +/// +/// # Returns +/// +/// - `Result` - The widened radius, rounded up. +pub fn pack_sphere_radius(center: Vector3D, radius: f64) -> Result { + let probe: f32 = pack_finite( + center.get_x(), + RAYTRACE_ERROR_NON_FINITE, + RAYTRACE_ERROR_F32_RANGE, + )?; + let widened: f64 = radius + half_ulp(probe) * 3.0_f64.sqrt(); + pack_upper_bound(widened) +} + +/// Packs one occluder into its eight `vec4` record. +/// +/// The record layout is frozen by the shader contract: +/// +/// ```text +/// 0: center.xyz, shape +/// 1: extent.xyz, material_kind +/// 2: vertex0.xyz, original_primitive_index +/// 3: vertex1.xyz, 0 +/// 4: vertex2.xyz, 0 +/// 5: albedo.xyz, specular +/// 6: emissive.xyz, shininess +/// 7: 0, 0, 0, 0 +/// ``` +/// +/// Spheres carry their radius in `extent.x` and are packed conservatively: +/// the centre rounds toward `-inf` on every axis and the radius is enlarged +/// by the same absolute error, so the packed sphere encloses the source +/// sphere after narrowing. AABBs are normalized so `center` is the minimum +/// corner and both bounds round outward. Triangles carry their three +/// vertices explicitly. +/// +/// # Arguments +/// +/// - `&mut Vec` - The destination packed buffer. +/// - `&Occluder` - The occluder to pack. +/// - `usize` - The original primitive index, stored as the tie breaker. +/// +/// # Returns +/// +/// - `Result<(), String>` - `Ok` once the record is appended, or the +/// rejection reason. +pub(crate) fn pack_primitive( + out: &mut Vec, + occluder: &Occluder, + original_index: usize, +) -> Result<(), String> { + let bound_index: f32 = pack_index(original_index)?; + let kind: OccluderKind = occluder.get_kind(); + let (center, extent): (Vector3D, Vector3D) = match kind { + OccluderKind::Aabb => { + let lo: Vector3D = occluder.get_center(); + let hi: Vector3D = occluder.get_extent(); + ( + Vector3D::new( + lo.get_x().min(hi.get_x()), + lo.get_y().min(hi.get_y()), + lo.get_z().min(hi.get_z()), + ), + Vector3D::new( + lo.get_x().max(hi.get_x()), + lo.get_y().max(hi.get_y()), + lo.get_z().max(hi.get_z()), + ), + ) + } + _ => (occluder.get_center(), occluder.get_extent()), + }; + let material: &Material = occluder.get_material(); + let center_packed: [f32; 3] = match kind { + OccluderKind::Sphere => { + pack_vector_lower(center, RAYTRACE_ERROR_NON_FINITE, RAYTRACE_ERROR_F32_RANGE)? + } + _ => pack_vector(center, RAYTRACE_ERROR_NON_FINITE, RAYTRACE_ERROR_F32_RANGE)?, + }; + let extent_packed: [f32; 3] = match kind { + OccluderKind::Sphere => { + let radius: f32 = pack_sphere_radius(center, extent.get_x())?; + [radius, 0.0, 0.0] + } + OccluderKind::Aabb => { + pack_vector_upper(extent, RAYTRACE_ERROR_NON_FINITE, RAYTRACE_ERROR_F32_RANGE)? + } + OccluderKind::Triangle => [0.0, 0.0, 0.0], + }; + let albedo: [f32; 3] = pack_vector( + material.get_albedo(), + RAYTRACE_ERROR_NON_FINITE, + RAYTRACE_ERROR_F32_RANGE, + )?; + let emissive: [f32; 3] = pack_vector( + material.get_emissive(), + RAYTRACE_ERROR_NON_FINITE, + RAYTRACE_ERROR_F32_RANGE, + )?; + let specular: f32 = pack_finite( + material.get_specular(), + RAYTRACE_ERROR_NON_FINITE, + RAYTRACE_ERROR_F32_RANGE, + )?; + let shininess: f32 = pack_finite( + material.get_shininess(), + RAYTRACE_ERROR_NON_FINITE, + RAYTRACE_ERROR_F32_RANGE, + )?; + let vertices: [Vector3D; 3] = occluder.get_vertices(); + push_vec4( + out, + center_packed[0], + center_packed[1], + center_packed[2], + packed_shape_code(kind), + ); + push_vec4( + out, + extent_packed[0], + extent_packed[1], + extent_packed[2], + packed_material_code(material.get_kind()), + ); + let vertex0: [f32; 3] = pack_vector( + vertices[0], + RAYTRACE_ERROR_NON_FINITE, + RAYTRACE_ERROR_F32_RANGE, + )?; + push_vec4(out, vertex0[0], vertex0[1], vertex0[2], bound_index); + let vertex1: [f32; 3] = pack_vector( + vertices[1], + RAYTRACE_ERROR_NON_FINITE, + RAYTRACE_ERROR_F32_RANGE, + )?; + push_vec4(out, vertex1[0], vertex1[1], vertex1[2], 0.0); + let vertex2: [f32; 3] = pack_vector( + vertices[2], + RAYTRACE_ERROR_NON_FINITE, + RAYTRACE_ERROR_F32_RANGE, + )?; + push_vec4(out, vertex2[0], vertex2[1], vertex2[2], 0.0); + push_vec4(out, albedo[0], albedo[1], albedo[2], specular); + push_vec4(out, emissive[0], emissive[1], emissive[2], shininess); + push_vec4(out, 0.0, 0.0, 0.0, 0.0); + Ok(()) +} + +/// Packs one BVH node into its three `vec4` record. +/// +/// The record layout is frozen by the shader contract: +/// +/// ```text +/// 0: lower.xyz, start +/// 1: upper.xyz, count +/// 2: escape, 0, 0, 0 +/// ``` +/// +/// `lower` rounds toward negative infinity and `upper` toward positive +/// infinity, so the packed node is always a conservative superset of the +/// CPU node and traversal cannot prune a subtree the CPU would have +/// visited. `start` indexes the reordered packed primitive records, and +/// `count` is zero for internal nodes, which both shaders detect to choose +/// between `start + 1` descent and an `escape` skip. +/// +/// # Arguments +/// +/// - `&mut Vec` - The destination, which keeps its capacity. +/// - `&RayTraceNode` - The node to pack. +/// +/// # Returns +/// +/// - `Result<(), String>` - `Ok` once three slots are appended, or the +/// first component that could not be represented. +pub(crate) fn pack_node(out: &mut Vec, node: &RayTraceNode) -> Result<(), String> { + let lower: [f32; 3] = pack_vector_lower( + node.get_lower(), + RAYTRACE_ERROR_NON_FINITE, + RAYTRACE_ERROR_F32_RANGE, + )?; + let upper: [f32; 3] = pack_vector_upper( + node.get_upper(), + RAYTRACE_ERROR_NON_FINITE, + RAYTRACE_ERROR_F32_RANGE, + )?; + let start: f32 = pack_index(node.get_start())?; + let count: f32 = pack_index(node.get_count())?; + let escape: f32 = pack_index(node.get_escape())?; + push_vec4(out, lower[0], lower[1], lower[2], start); + push_vec4(out, upper[0], upper[1], upper[2], count); + push_vec4(out, escape, 0.0, 0.0, 0.0); + Ok(()) +} + +/// Validates one frame's camera and returns its basis. +/// +/// This is the single place a frame is accepted or rejected: the packed +/// header writer and the CPU pixel loop both go through it, so they can +/// never disagree about which cameras render and which do not. The checks +/// are the dimensions being non-zero and not overflowing the packed-buffer +/// limit, a field of view strictly inside `(0, PI)`, a depth range +/// satisfying `0 <= near < far` that survives the `f32` narrowing without +/// collapsing, a finite eye, and a non-degenerate basis. +/// +/// Nothing here allocates: the result is a stack value, so a caller +/// rendering every frame pays no heap cost for validation, and the CPU +/// renderer needs no packed buffer at all. +/// +/// # Arguments +/// +/// - `&Camera3D` - The camera supplying the eye, target, up, fov, and +/// depth range. +/// - `u32` - The frame width in pixels. +/// - `u32` - The frame height in pixels. +/// +/// # Returns +/// +/// - `Result` - The validated basis, or the reason the +/// frame cannot be rendered. +pub(crate) fn frame_basis( + camera: &Camera3D, + width: u32, + height: u32, +) -> Result { + if width == 0 || height == 0 { + return Err(RAYTRACE_ERROR_ZERO_DIMENSION.to_string()); + } + let pixel_count: usize = match (width as usize).checked_mul(height as usize) { + Some(product) => product, + None => return Err(RAYTRACE_ERROR_DIMENSION_OVERFLOW.to_string()), + }; + if pixel_count > RAYTRACE_MAX_PACKED_FLOATS { + return Err(RAYTRACE_ERROR_DIMENSION_OVERFLOW.to_string()); + } + let eye: Vector3D = camera.get_position(); + if !eye.get_x().is_finite() || !eye.get_y().is_finite() || !eye.get_z().is_finite() { + return Err(RAYTRACE_ERROR_CAMERA_NON_FINITE.to_string()); + } + let fov: f64 = camera.get_fov(); + let fov_usable: bool = fov.is_finite() && fov > 0.0 && fov < PI; + if !fov_usable { + return Err(RAYTRACE_ERROR_CAMERA_FOV.to_string()); + } + let tan_half_fov: f64 = (fov * 0.5).tan(); + let tan_usable: bool = tan_half_fov.is_finite() && tan_half_fov > 0.0; + if !tan_usable { + return Err(RAYTRACE_ERROR_CAMERA_FOV.to_string()); + } + let forward: Vector3D = camera.forward(); + if forward == Vector3D::zero() { + return Err(RAYTRACE_ERROR_CAMERA_BASIS.to_string()); + } + let right: Vector3D = forward.cross(camera.get_up()).normalized(); + let up: Vector3D = right.cross(forward); + if right == Vector3D::zero() || up == Vector3D::zero() { + return Err(RAYTRACE_ERROR_CAMERA_BASIS.to_string()); + } + let near_t: f64 = camera.get_near(); + let far_t: f64 = camera.get_far(); + let range_ordered: bool = + near_t.is_finite() && far_t.is_finite() && near_t >= 0.0 && far_t > near_t; + if !range_ordered { + return Err(RAYTRACE_ERROR_CAMERA_RANGE.to_string()); + } + let near_packed: f32 = pack_finite( + near_t, + RAYTRACE_ERROR_CAMERA_NON_FINITE, + RAYTRACE_ERROR_CAMERA_F32_RANGE, + )?; + let far_packed: f32 = pack_finite( + far_t, + RAYTRACE_ERROR_CAMERA_NON_FINITE, + RAYTRACE_ERROR_CAMERA_F32_RANGE, + )?; + let packed_range_ordered: bool = far_packed > near_packed; + if !packed_range_ordered { + return Err(RAYTRACE_ERROR_CAMERA_RANGE.to_string()); + } + Ok(FrameBasis { + eye, + forward, + right, + up, + tan_half_fov, + aspect: width as f64 / height as f64, + width, + height, + near_t, + far_t, + }) +} + +/// Packs the per-frame camera, lighting, and settings block consumed by +/// both shader backends. +/// +/// The buffer layout is frozen by the shader contract: +/// +/// ```text +/// 0: eye.xyz, tan(fov / 2) +/// 1: forward.xyz, aspect +/// 2: right.xyz, width +/// 3: up.xyz, height +/// 4: ambient.xyz, light_count +/// 5: lighting_eye.xyz, max_bounces +/// 6: near_t, far_t, samples_per_axis, 0 +/// 7: 0, 0, 0, 0 +/// ``` +/// +/// followed by four `vec4` per light: +/// +/// ```text +/// 0: position.xyz, kind +/// 1: direction.xyz, intensity +/// 2: color.xyz, falloff +/// 3: spot_cos, 0, 0, 0 +/// ``` +/// +/// The camera basis is recomputed from `position`, `target`, and `up`, so a +/// caller never supplies a basis that disagrees with `target`. A degenerate +/// basis — a zero forward, a zero right, or an up parallel to forward — is +/// rejected rather than packed as a zero direction that would collapse every +/// ray onto one axis. The field of view must lie in `(0, PI)` and the depth +/// range must satisfy `0 <= near < far`; both are packed through +/// [`pack_finite`], so a `f64` far plane beyond the `f32` range is reported +/// rather than written as infinity. +/// +/// The light count is taken from the input and never capped: a frame with +/// more lights than the packed-buffer limit allows is reported through the +/// overflow check, not silently truncated. +/// +/// The supplied `out` is cleared and then reused, so its capacity is kept +/// and a caller packing every frame performs no allocation. +/// +/// # Arguments +/// +/// - `&mut Vec` - The destination packed buffer, cleared then reused. +/// - `&Camera3D` - The camera supplying the eye, target, up, and fov. +/// - `&LightingUniforms` - The lights, ambient, and shading eye. +/// - `&RayTraceSettings` - The sampling and bounce settings. +/// - `u32` - The frame width in pixels. +/// - `u32` - The frame height in pixels. +/// +/// # Returns +/// +/// - `Result<(), String>` - `Ok` once the block is packed, or the reason +/// the frame cannot be represented. +pub fn pack_raytrace_frame( + out: &mut Vec, + camera: &Camera3D, + lights: &LightingUniforms, + settings: &RayTraceSettings, + width: u32, + height: u32, +) -> Result<(), String> { + settings.validate()?; + let basis: FrameBasis = frame_basis(camera, width, height)?; + let light_count: usize = lights.get_lights().len(); + let light_slots: usize = light_count + .checked_mul(RAYTRACE_PACKED_LIGHT_VEC4S) + .ok_or_else(|| RAYTRACE_ERROR_FRAME_OVERFLOW.to_string())?; + let total_slots: usize = RAYTRACE_PACKED_FRAME_HEADER_VEC4S + .checked_add(light_slots) + .ok_or_else(|| RAYTRACE_ERROR_FRAME_OVERFLOW.to_string())?; + let total_floats: usize = total_slots + .checked_mul(4) + .ok_or_else(|| RAYTRACE_ERROR_FRAME_OVERFLOW.to_string())?; + if total_floats > RAYTRACE_MAX_PACKED_FLOATS { + return Err(RAYTRACE_ERROR_FRAME_OVERFLOW.to_string()); + } + let eye: [f32; 3] = pack_vector( + camera.get_position(), + RAYTRACE_ERROR_CAMERA_NON_FINITE, + RAYTRACE_ERROR_CAMERA_NON_FINITE, + )?; + let forward_packed: [f32; 3] = pack_vector( + basis.get_forward(), + RAYTRACE_ERROR_CAMERA_NON_FINITE, + RAYTRACE_ERROR_CAMERA_NON_FINITE, + )?; + let right_packed: [f32; 3] = pack_vector( + basis.get_right(), + RAYTRACE_ERROR_CAMERA_NON_FINITE, + RAYTRACE_ERROR_CAMERA_NON_FINITE, + )?; + let up_packed: [f32; 3] = pack_vector( + basis.get_up(), + RAYTRACE_ERROR_CAMERA_NON_FINITE, + RAYTRACE_ERROR_CAMERA_NON_FINITE, + )?; + let tan_half_fov: f32 = pack_finite( + basis.get_tan_half_fov(), + RAYTRACE_ERROR_CAMERA_NON_FINITE, + RAYTRACE_ERROR_CAMERA_NON_FINITE, + )?; + let ambient: [f32; 3] = pack_vector( + lights.get_ambient(), + RAYTRACE_ERROR_LIGHT_NON_FINITE, + RAYTRACE_ERROR_LIGHT_F32_RANGE, + )?; + let lighting_eye: [f32; 3] = pack_vector( + lights.get_eye(), + RAYTRACE_ERROR_LIGHT_NON_FINITE, + RAYTRACE_ERROR_LIGHT_F32_RANGE, + )?; + out.clear(); + push_vec4(out, eye[0], eye[1], eye[2], tan_half_fov); + push_vec4( + out, + forward_packed[0], + forward_packed[1], + forward_packed[2], + basis.get_aspect() as f32, + ); + push_vec4( + out, + right_packed[0], + right_packed[1], + right_packed[2], + basis.get_width() as f32, + ); + push_vec4( + out, + up_packed[0], + up_packed[1], + up_packed[2], + basis.get_height() as f32, + ); + push_vec4(out, ambient[0], ambient[1], ambient[2], light_count as f32); + push_vec4( + out, + lighting_eye[0], + lighting_eye[1], + lighting_eye[2], + settings.get_max_bounces() as f32, + ); + push_vec4( + out, + basis.get_near_t() as f32, + basis.get_far_t() as f32, + settings.get_samples_per_axis() as f32, + 0.0, + ); + push_vec4(out, 0.0, 0.0, 0.0, 0.0); + for light in lights.get_lights().iter() { + let position: [f32; 3] = pack_vector( + light.get_position(), + RAYTRACE_ERROR_LIGHT_NON_FINITE, + RAYTRACE_ERROR_LIGHT_F32_RANGE, + )?; + let direction: [f32; 3] = pack_vector( + light.get_direction(), + RAYTRACE_ERROR_LIGHT_NON_FINITE, + RAYTRACE_ERROR_LIGHT_F32_RANGE, + )?; + let color: [f32; 3] = pack_vector( + light.get_color(), + RAYTRACE_ERROR_LIGHT_NON_FINITE, + RAYTRACE_ERROR_LIGHT_F32_RANGE, + )?; + let intensity: f32 = pack_finite( + light.get_intensity(), + RAYTRACE_ERROR_LIGHT_NON_FINITE, + RAYTRACE_ERROR_LIGHT_F32_RANGE, + )?; + let falloff: f32 = pack_finite( + light.get_falloff(), + RAYTRACE_ERROR_LIGHT_NON_FINITE, + RAYTRACE_ERROR_LIGHT_F32_RANGE, + )?; + let spot_cos: f32 = pack_finite( + light.get_spot_cos(), + RAYTRACE_ERROR_LIGHT_NON_FINITE, + RAYTRACE_ERROR_LIGHT_F32_RANGE, + )?; + push_vec4( + out, + position[0], + position[1], + position[2], + packed_light_code(light.get_kind()), + ); + push_vec4(out, direction[0], direction[1], direction[2], intensity); + push_vec4(out, color[0], color[1], color[2], falloff); + push_vec4(out, spot_cos, 0.0, 0.0, 0.0); + } + Ok(()) +} diff --git a/engine/src/raytracing/pipeline/data/impl.rs b/engine/src/raytracing/pipeline/data/impl.rs new file mode 100644 index 00000000..04888436 --- /dev/null +++ b/engine/src/raytracing/pipeline/data/impl.rs @@ -0,0 +1,151 @@ +use super::*; + +/// Implements the constructors and validation for [`RayTraceSettings`]. +impl RayTraceSettings { + /// Returns the default settings: one ray per pixel and the + /// [`RAYTRACE_DEFAULT_MAX_BOUNCES_LIMIT`] bounce limit. + /// + /// # Returns + /// + /// - `RayTraceSettings` - The default settings. + pub fn new_default() -> RayTraceSettings { + RayTraceSettings::new( + RAYTRACE_DEFAULT_SAMPLES_PER_AXIS, + RAYTRACE_DEFAULT_MAX_BOUNCES_LIMIT, + ) + } + + /// Returns a settings block with an explicit sampling grid and bounce + /// limit. + /// + /// The arguments are not validated here; call + /// [`RayTraceSettings::validate`] before packing or rendering. + /// + /// # Arguments + /// + /// - `u32` - The supersampling grid resolution per axis. + /// - `u32` - The maximum number of secondary bounces. + /// + /// # Returns + /// + /// - `RayTraceSettings` - The new settings. + pub fn with_quality(samples_per_axis: u32, max_bounces: u32) -> RayTraceSettings { + RayTraceSettings::new(samples_per_axis, max_bounces) + } + + /// Returns the total number of rays traced per pixel, which is + /// `samples_per_axis` squared. + /// + /// # Returns + /// + /// - `u32` - The per-pixel ray count. + pub fn rays_per_pixel(&self) -> u32 { + let axis: u32 = self.get_samples_per_axis(); + axis * axis + } + + /// Checks that both fields are inside the range the CPU renderer and + /// both shaders support. + /// + /// `samples_per_axis` must be within + /// `1..=`[`RAYTRACE_SAMPLES_PER_AXIS_MAX`] and `max_bounces` within + /// `0..=`[`RAYTRACE_MAX_BOUNCES_MAX`]. Anything else is rejected rather + /// than clamped, so a caller never silently gets a different image than + /// it asked for. + /// + /// # Returns + /// + /// - `Result<(), String>` - `Ok(())` when both fields are in range, + /// otherwise a message naming the offending field. + pub fn validate(&self) -> Result<(), String> { + let samples: u32 = self.get_samples_per_axis(); + if !(RAYTRACE_SAMPLES_PER_AXIS_MIN..=RAYTRACE_SAMPLES_PER_AXIS_MAX).contains(&samples) { + return Err(RAYTRACE_ERROR_SAMPLES_PER_AXIS.to_string()); + } + let bounces: u32 = self.get_max_bounces(); + if !(RAYTRACE_MAX_BOUNCES_MIN..=RAYTRACE_MAX_BOUNCES_MAX).contains(&bounces) { + return Err(RAYTRACE_ERROR_MAX_BOUNCES.to_string()); + } + Ok(()) + } +} + +/// Implements the GPU packing entry point for [`RayTraceScene`]. +impl RayTraceScene { + /// Packs the scene into the `vec4` array both shader backends read. + /// + /// The layout is frozen by the shader contract: + /// + /// ```text + /// vec4[0]: node_count, primitive_count, primitive_offset, 0 + /// vec4[1 + 3 * n]: node lower.xyz, start + /// vec4[2 + 3 * n]: node upper.xyz, count + /// vec4[3 + 3 * n]: node escape, 0, 0, 0 + /// vec4[primitive_offset + 8 * p]: primitive records + /// ``` + /// + /// Primitives are stored in `indices` order so a leaf's `start` field + /// reads directly into the packed primitive array while each record + /// still carries the original primitive index the closest-hit tie + /// breaker compares. The source occluder ordering is never altered. + /// + /// An empty scene packs a two-slot header with `node_count == 0`, which + /// both shaders detect and treat as "every ray misses". + /// + /// Non-finite geometry components, components whose magnitude exceeds + /// the finite `f32` range, and indices above `2^24` (the exact-integer + /// limit of `f32`) are rejected rather than truncated, because a + /// truncated node bound would round inward and lose real geometry. + /// + /// # Returns + /// + /// - `Result, String>` - The packed `vec4` array, or the + /// reason the scene cannot be represented on a GPU. + pub fn encode_gpu(&self) -> Result, String> { + let nodes: &[RayTraceNode] = self.get_nodes(); + let indices: &[usize] = self.get_indices(); + let node_count: usize = nodes.len(); + let primitive_count: usize = indices.len(); + let node_slots: usize = node_count + .checked_mul(RAYTRACE_PACKED_NODE_VEC4S) + .ok_or_else(|| RAYTRACE_ERROR_SCENE_OVERFLOW.to_string())?; + let primitive_slots: usize = primitive_count + .checked_mul(RAYTRACE_PACKED_PRIMITIVE_VEC4S) + .ok_or_else(|| RAYTRACE_ERROR_SCENE_OVERFLOW.to_string())?; + let total_slots: usize = RAYTRACE_PACKED_SCENE_HEADER_VEC4S + .checked_add(node_slots) + .and_then(|sum: usize| sum.checked_add(primitive_slots)) + .ok_or_else(|| RAYTRACE_ERROR_SCENE_OVERFLOW.to_string())?; + let total_floats: usize = total_slots + .checked_mul(4) + .ok_or_else(|| RAYTRACE_ERROR_SCENE_OVERFLOW.to_string())?; + if total_floats > RAYTRACE_MAX_PACKED_FLOATS { + return Err(RAYTRACE_ERROR_SCENE_OVERFLOW.to_string()); + } + let mut out: Vec = Vec::with_capacity(total_floats); + let primitive_offset: usize = RAYTRACE_PACKED_SCENE_HEADER_VEC4S + node_slots; + push_vec4( + &mut out, + pack_index(node_count)?, + pack_index(primitive_count)?, + pack_index(primitive_offset)?, + 0.0, + ); + for node in nodes.iter() { + pack_node(&mut out, node)?; + } + let occluders: &[Occluder] = self.get_occluders(); + for index in indices.iter() { + let occluder: &Occluder = occluders.get(*index).ok_or_else(|| { + format!( + "{}: primitive index {} is out of range for {} occluders", + RAYTRACE_ERROR_INDEX_RANGE, + index, + occluders.len() + ) + })?; + pack_primitive(&mut out, occluder, *index)?; + } + Ok(out) + } +} diff --git a/engine/src/raytracing/pipeline/data/mod.rs b/engine/src/raytracing/pipeline/data/mod.rs new file mode 100644 index 00000000..07740211 --- /dev/null +++ b/engine/src/raytracing/pipeline/data/mod.rs @@ -0,0 +1,10 @@ +mod r#const; +mod r#fn; +mod r#impl; +mod r#struct; + +pub use {r#fn::*, r#struct::RayTraceSettings}; + +pub(crate) use {r#const::*, r#struct::FrameBasis}; + +use super::*; diff --git a/engine/src/raytracing/pipeline/data/struct.rs b/engine/src/raytracing/pipeline/data/struct.rs new file mode 100644 index 00000000..4714ff15 --- /dev/null +++ b/engine/src/raytracing/pipeline/data/struct.rs @@ -0,0 +1,62 @@ +use super::*; + +/// Ray-tracing quality settings shared by the CPU reference renderer and +/// both GPU shader backends. +/// +/// The values are packed into the GPU frame header so the CPU path and the +/// shaders sample and bounce identically. +#[derive(Clone, Copy, Data, Debug, New, PartialEq)] +pub struct RayTraceSettings { + /// Supersampling grid resolution per axis. `1` traces one ray per + /// pixel; `4` traces sixteen and averages them. Valid range 1..=4. + #[get(type(copy))] + pub samples_per_axis: u32, + /// Maximum number of secondary bounces after the primary hit. `0` + /// still shades the primary hit. Valid range 0..=16. + #[get(type(copy))] + pub max_bounces: u32, +} + +/// A validated, immutable description of one frame's camera. +/// +/// Building a [`FrameBasis`] performs every check the packed frame header +/// and the CPU pixel loop both need: the settings are in range, the +/// dimensions are usable, the field of view lies in `(0, PI)`, the depth +/// range satisfies `0 <= near < far` and survives the `f32` narrowing, and +/// the camera basis is non-degenerate. Holding the result means neither +/// path has to re-derive the basis or re-run the validation, and neither +/// can accept a frame the other rejects. +#[derive(Clone, Copy, Data, Debug, PartialEq)] +pub(crate) struct FrameBasis { + /// The world-space eye position. + #[get(pub(crate), type(copy))] + pub(crate) eye: Vector3D, + /// The unit forward direction, from the eye toward the target. + #[get(pub(crate), type(copy))] + pub(crate) forward: Vector3D, + /// The unit right direction, `forward` crossed with the camera up. + #[get(pub(crate), type(copy))] + pub(crate) right: Vector3D, + /// The unit up direction, `right` crossed with `forward`. + #[get(pub(crate), type(copy))] + pub(crate) up: Vector3D, + /// `tan(fov / 2)`, the factor that makes the rendered vertical field + /// of view exactly `fov`. + #[get(pub(crate), type(copy))] + pub(crate) tan_half_fov: f64, + /// `width / height`. + #[get(pub(crate), type(copy))] + pub(crate) aspect: f64, + /// The frame width in pixels. + #[get(pub(crate), type(copy))] + pub(crate) width: u32, + /// The frame height in pixels. + #[get(pub(crate), type(copy))] + pub(crate) height: u32, + /// The near clipping distance used as the primary ray's `t_min`. + #[get(pub(crate), type(copy))] + pub(crate) near_t: f64, + /// The far clipping distance used as the primary ray's `t_max`. + #[get(pub(crate), type(copy))] + pub(crate) far_t: f64, +} diff --git a/engine/src/raytracing/pipeline/mod.rs b/engine/src/raytracing/pipeline/mod.rs new file mode 100644 index 00000000..42b752bd --- /dev/null +++ b/engine/src/raytracing/pipeline/mod.rs @@ -0,0 +1,7 @@ +mod r#data; +mod r#render; +mod r#shader; + +pub use {r#data::*, r#render::render_raytrace_rgba, r#shader::*}; + +use super::*; diff --git a/engine/src/raytracing/pipeline/render/fn.rs b/engine/src/raytracing/pipeline/render/fn.rs new file mode 100644 index 00000000..b58fc1f5 --- /dev/null +++ b/engine/src/raytracing/pipeline/render/fn.rs @@ -0,0 +1,148 @@ +use super::*; + +/// Builds the world-space direction for one supersample of one pixel. +/// +/// The camera ray is +/// `normalize(forward + right * (ndc_x * aspect * tan(fov/2)) + up * +/// (ndc_y * tan(fov/2)))`. The `tan(fov/2)` factor is what makes the +/// rendered vertical field of view exactly `fov`; omitting it renders +/// wider than the camera asks for and no longer matches either shader, +/// which both apply it. +/// +/// Rows are addressed top-down, matching the WGSL fragment position and the +/// `gl_FragCoord`-flipped WebGL2 ordering, so all three backends agree row +/// for row. +/// +/// # Arguments +/// +/// - `&FrameBasis` - The validated camera basis. +/// - `f64` - The pixel's x coordinate. +/// - `f64` - The pixel's y coordinate, counted from the top row. +/// - `f64` - The sub-sample's offset within the pixel, in `[0, 1)`. +/// - `f64` - The second sub-sample's offset within the pixel. +/// +/// # Returns +/// +/// - `Vector3D` - The unit world-space ray direction. +pub(crate) fn pixel_direction( + basis: &FrameBasis, + x: f64, + y: f64, + offset_x: f64, + offset_y: f64, +) -> Vector3D { + let ndc_x: f64 = ((x + offset_x) / basis.get_width() as f64) * 2.0 - 1.0; + let ndc_y: f64 = 1.0 - ((y + offset_y) / basis.get_height() as f64) * 2.0; + let tan_half: f64 = basis.get_tan_half_fov(); + (basis.get_forward() + + basis + .get_right() + .scaled(ndc_x * basis.get_aspect() * tan_half) + + basis.get_up().scaled(ndc_y * tan_half)) + .normalized() +} + +/// Converts one linear channel to a gamma-corrected byte. +/// +/// The clamp to `[0, 1]` happens before the curve and the result is +/// rounded, matching both shaders' `pow(clamp(c, 0, 1), 1/2.2)`. +/// +/// # Arguments +/// +/// - `f64` - The linear channel, which may fall outside `[0, 1]`. +/// +/// # Returns +/// +/// - `u8` - The gamma-corrected byte, clamped to `[0, 255]`. +pub fn gamma_byte(channel: f64) -> u8 { + let clamped: f64 = channel.clamp(0.0, 1.0); + let encoded: f64 = clamped.powf(1.0 / RAYTRACE_GAMMA); + (encoded * 255.0).round().clamp(0.0, 255.0) as u8 +} + +/// Renders a ray-traced RGBA frame on the CPU. +/// +/// This is the CPU reference and the Canvas 2D backend's renderer. It does +/// not re-implement traversal or shading: each pixel builds a [`Ray`] — a +/// stack value, so no allocation happens per pixel, per bounce, or per +/// frame — and hands it to [`RayTraceScene::trace_with_bounces`], which +/// runs the scene's own bounding volume hierarchy and the real +/// [`LightingUniforms`] shading. The two GPU backends implement the same +/// math in shader form against the packed buffers; they are the only +/// duplicates, and they must agree with this path rather than with each +/// other. +/// +/// The frame is described by a [`FrameBasis`], a stack value built by the +/// same validator [`pack_raytrace_frame`] uses, so this path never packs a +/// GPU buffer, never re-encodes the scene, and allocates nothing at all +/// beyond the caller-owned `out`. A steady-state frame therefore performs +/// exactly one heap operation: none. +/// +/// Each pixel traces `samples_per_axis` squared supersampled rays, averages +/// them, clamps the linear color to `[0, 1]`, applies the `1 / 2.2` gamma +/// curve, and writes an opaque alpha. Rows are emitted top-down. +/// +/// # Arguments +/// +/// - `&RayTraceScene` - The scene to render. +/// - `&Camera3D` - The camera supplying the eye, target, up, fov, and +/// near/far range. +/// - `&LightingUniforms` - The lights, ambient, and shading eye. +/// - `&RayTraceSettings` - The sampling grid and bounce limit. +/// - `u32` - The frame width in pixels. +/// - `u32` - The frame height in pixels. +/// - `&mut Vec` - The destination RGBA buffer, cleared and reused. +/// +/// # Returns +/// +/// - `Result<(), String>` - `Ok` once the frame is written, or the reason +/// the frame cannot be rendered. +pub fn render_raytrace_rgba( + scene: &RayTraceScene, + camera: &Camera3D, + lights: &LightingUniforms, + settings: &RayTraceSettings, + width: u32, + height: u32, + out: &mut Vec, +) -> Result<(), String> { + settings.validate()?; + let basis: FrameBasis = frame_basis(camera, width, height)?; + let pixel_count: usize = (basis.get_width() as usize) * (basis.get_height() as usize); + let byte_count: usize = match pixel_count.checked_mul(4) { + Some(count) => count, + None => return Err(RAYTRACE_ERROR_DIMENSION_OVERFLOW.to_string()), + }; + let samples: u32 = settings.get_samples_per_axis(); + let sample_scale: f64 = 1.0 / samples as f64; + let rays: f64 = (samples * samples) as f64; + let bounces: u32 = settings.get_max_bounces(); + out.clear(); + out.resize(byte_count, 0); + for y in 0..basis.get_height() { + for x in 0..basis.get_width() { + let mut accumulator: Vector3D = Vector3D::zero(); + for sy in 0..samples { + for sx in 0..samples { + let direction: Vector3D = pixel_direction( + &basis, + x as f64, + y as f64, + (sx as f64 + 0.5) * sample_scale, + (sy as f64 + 0.5) * sample_scale, + ); + let ray: Ray = Ray::new(basis.get_eye(), direction) + .with_range(basis.get_near_t(), basis.get_far_t()); + accumulator += scene.trace_with_bounces(ray, lights, bounces); + } + } + let linear: Vector3D = accumulator.scaled(1.0 / rays); + let offset: usize = ((y as usize) * (basis.get_width() as usize) + x as usize) * 4; + out[offset] = gamma_byte(linear.get_x()); + out[offset + 1] = gamma_byte(linear.get_y()); + out[offset + 2] = gamma_byte(linear.get_z()); + out[offset + 3] = 255; + } + } + Ok(()) +} diff --git a/engine/src/raytracing/pipeline/render/mod.rs b/engine/src/raytracing/pipeline/render/mod.rs new file mode 100644 index 00000000..7087a15a --- /dev/null +++ b/engine/src/raytracing/pipeline/render/mod.rs @@ -0,0 +1,5 @@ +mod r#fn; + +pub use r#fn::*; + +use super::*; diff --git a/engine/src/raytracing/pipeline/shader/glsl/const.rs b/engine/src/raytracing/pipeline/shader/glsl/const.rs new file mode 100644 index 00000000..b5241882 --- /dev/null +++ b/engine/src/raytracing/pipeline/shader/glsl/const.rs @@ -0,0 +1,554 @@ +/// The GLSL ES 3.00 vertex shader for the WebGL2 ray-tracing backend. +/// +/// It emits a single attribute-less fullscreen triangle from +/// `gl_VertexID`, so the program needs no vertex buffer and no vertex +/// array object contents. The rasterized triangle covers the whole viewport +/// and the fragment shader derives its own pixel coordinates. +pub(crate) const RAYTRACE_GLSL_VERTEX: &str = r#"#version 300 es + +void main() { + vec2 positions[3] = vec2[3]( + vec2(-1.0, -1.0), + vec2(3.0, -1.0), + vec2(-1.0, 3.0) + ); + gl_Position = vec4(positions[gl_VertexID], 0.0, 1.0); +} +"#; + +/// The GLSL ES 3.00 fragment shader for the WebGL2 ray-tracing backend. +/// +/// It reads the same packed scene and packed frame buffers the WGSL +/// backend and the CPU reference renderer read, uploaded as two +/// `RGBA32F` textures with `NEAREST` filtering and `CLAMP_TO_EDGE` +/// addressing. Every fetch is an unfiltered `texelFetch`, so no float +/// filtering extension is required. The packed array is addressed as +/// `vec4` slots: slot `i` lives at texel `(i % width, i / width)`. +/// +/// Nothing about the scene is baked into the source: the node count, the +/// primitive count, the light count, the sample grid, the bounce limit and +/// the camera all arrive through the two data textures, so one program +/// serves any scene. +pub(crate) const RAYTRACE_GLSL_FRAGMENT: &str = r#"#version 300 es + +precision highp float; +precision highp int; +precision highp sampler2D; + +uniform sampler2D u_scene; +uniform sampler2D u_frame; + +// The vec4 slot count in the packed scene buffer. +const int SCENE_SLOTS = 1; +// The vec4 slot count in one packed BVH node. +const int NODE_SLOTS = 3; +// The vec4 slot count in one packed primitive record. +const int PRIMITIVE_SLOTS = 8; +// The vec4 slot count in the packed frame header. +const int FRAME_HEADER_SLOTS = 8; +// The vec4 slot count in one packed light record. +const int LIGHT_SLOTS = 4; + +const float EPS = 1e-6; +const float T_MIN_SECONDARY = 0.001; +const float T_MAX_SECONDARY = 1000.0; +const float SURFACE_OFFSET = 1e-3; +const float PBR_BASE = 0.04; +const float GAMMA = 2.2; + +out vec4 out_color; + +vec4 fetch_scene(int slot) { + ivec2 size = textureSize(u_scene, 0); + int width = size.x; + return texelFetch(u_scene, ivec2(slot - (slot / width) * width, slot / width), 0); +} + +vec4 fetch_frame(int slot) { + ivec2 size = textureSize(u_frame, 0); + int width = size.x; + return texelFetch(u_frame, ivec2(slot - (slot / width) * width, slot / width), 0); +} + +vec3 safe_normalize(vec3 v) { + float len = length(v); + if (len <= EPS) { + return vec3(0.0); + } + return v / len; +} + +// Mirrors the engine slab test: a ray parallel to a slab and outside it +// rejects the node, and one inside it imposes no constraint. +bool slab_interval(float o, float d, float lo, float hi, out float enter, out float exit) { + if (abs(d) < EPS) { + if (o < lo || o > hi) { + return false; + } + enter = -1.0e30; + exit = 1.0e30; + return true; + } + float t0 = (lo - o) / d; + float t1 = (hi - o) / d; + enter = min(t0, t1); + exit = max(t0, t1); + return true; +} + +bool node_overlaps( + vec3 origin, + vec3 dir, + float t_min, + float t_max, + vec3 lower, + vec3 upper +) { + vec3 o = origin; + vec3 d = dir; + vec3 lo = lower; + vec3 hi = upper; + float enter; + float exit; + if (!slab_interval(o.x, d.x, lo.x, hi.x, enter, exit)) { + return false; + } + float t_enter = enter; + float t_exit = exit; + if (!slab_interval(o.y, d.y, lo.y, hi.y, enter, exit)) { + return false; + } + t_enter = max(t_enter, enter); + t_exit = min(t_exit, exit); + if (!slab_interval(o.z, d.z, lo.z, hi.z, enter, exit)) { + return false; + } + t_enter = max(t_enter, enter); + t_exit = min(t_exit, exit); + t_enter = max(t_enter, t_min); + t_exit = min(t_exit, t_max); + return t_enter <= t_exit; +} + +// Two-sided Moller-Trumbore, matching the engine intersect_triangle. +bool triangle_hit( + vec3 origin, + vec3 dir, + vec3 v0, + vec3 v1, + vec3 v2, + float t_min, + float t_max, + out float t, + out vec3 normal +) { + vec3 edge1 = v1 - v0; + vec3 edge2 = v2 - v0; + vec3 pvec = cross(dir, edge2); + float det = dot(edge1, pvec); + if (abs(det) < EPS) { + return false; + } + float inverse_det = 1.0 / det; + vec3 tvec = origin - v0; + float u = dot(tvec, pvec) * inverse_det; + if (u < 0.0 || u > 1.0) { + return false; + } + vec3 qvec = cross(tvec, edge1); + float v = dot(dir, qvec) * inverse_det; + if (v < 0.0 || u + v > 1.0) { + return false; + } + t = dot(edge2, qvec) * inverse_det; + if (t < t_min || t > t_max) { + return false; + } + vec3 geometric = safe_normalize(cross(edge1, edge2)); + normal = dot(geometric, dir) > 0.0 ? -geometric : geometric; + return true; +} + +// The quadratic form resolves both roots, so a ray starting inside the +// solid exits rather than reporting a behind-the-origin miss. The +// direction need not be unit length: the `a` term keeps the roots correct. +bool primitive_hit( + int slot, + vec3 origin, + vec3 dir, + float t_min, + float t_max, + out float t, + out vec3 normal +) { + vec4 shape = fetch_scene(slot); + vec4 extent = fetch_scene(slot + 1); + vec4 vertex0 = fetch_scene(slot + 2); + vec4 vertex1 = fetch_scene(slot + 3); + vec4 vertex2 = fetch_scene(slot + 4); + int kind = int(shape.w + 0.5); + if (kind == 2) { + return triangle_hit( + origin, + dir, + vertex0.xyz, + vertex1.xyz, + vertex2.xyz, + t_min, + t_max, + t, + normal + ); + } + if (kind == 1) { + vec3 lo = shape.xyz; + vec3 hi = extent.xyz; + vec3 o = origin; + vec3 d = dir; + float enter; + float exit; + if (!slab_interval(o.x, d.x, lo.x, hi.x, enter, exit)) { + return false; + } + float t_near = enter; + float t_far = exit; + if (!slab_interval(o.y, d.y, lo.y, hi.y, enter, exit)) { + return false; + } + t_near = max(t_near, enter); + t_far = min(t_far, exit); + if (!slab_interval(o.z, d.z, lo.z, hi.z, enter, exit)) { + return false; + } + t_near = max(t_near, enter); + t_far = min(t_far, exit); + if (t_near > t_far || t_far < t_min) { + return false; + } + float hit_t = t_near < t_min ? t_far : t_near; + if (hit_t < t_min || hit_t > t_max) { + return false; + } + vec3 hit = origin + dir * hit_t; + vec3 center = (lo + hi) * 0.5; + vec3 delta = hit - center; + vec3 half_extent = (hi - lo) * 0.5; + vec3 scaled = abs(delta) / max(half_extent, vec3(EPS)); + if (scaled.x >= scaled.y && scaled.x >= scaled.z) { + normal = vec3(sign(delta.x), 0.0, 0.0); + } else if (scaled.y >= scaled.z) { + normal = vec3(0.0, sign(delta.y), 0.0); + } else { + normal = vec3(0.0, 0.0, sign(delta.z)); + } + t = hit_t; + return true; + } + vec3 center = shape.xyz; + float radius = extent.x; + vec3 oc = origin - center; + float a = dot(dir, dir); + if (a <= EPS) { + return false; + } + float b = dot(oc, dir); + float c = dot(oc, oc) - radius * radius; + float disc = b * b - a * c; + if (disc < 0.0) { + return false; + } + float sq = sqrt(disc); + float near_t = (-b - sq) / a; + float far_t = (-b + sq) / a; + float hit_t = near_t < t_min ? far_t : near_t; + if (hit_t < t_min || hit_t > t_max) { + return false; + } + vec3 hit = origin + dir * hit_t; + t = hit_t; + normal = safe_normalize(hit - center); + return true; +} + +struct Hit { + bool found; + float t; + vec3 position; + vec3 normal; + float specular; + float shininess; + vec3 emissive; + vec3 albedo; + int material_kind; + float original_index; +}; + +// Ordered traversal over the packed preorder node array: an internal-node +// hit visits index + 1, a miss or a leaf jumps to escape. The iteration is +// bounded by node_count so a malformed buffer cannot hang the shader, and +// ties on t resolve to the lowest original primitive index. +Hit closest_hit(vec3 origin, vec3 dir, float t_min, float t_max) { + vec4 header = fetch_scene(0); + int node_count = int(header.x + 0.5); + int primitive_count = int(header.y + 0.5); + int primitive_offset = int(header.z + 0.5); + Hit best; + best.found = false; + best.t = 0.0; + best.position = vec3(0.0); + best.normal = vec3(0.0, 1.0, 0.0); + best.specular = 0.0; + best.shininess = 0.0; + best.emissive = vec3(0.0); + best.albedo = vec3(0.0); + best.material_kind = 0; + best.original_index = 0.0; + if (node_count <= 0 || primitive_count <= 0) { + return best; + } + int index = 0; + int budget = node_count * 2 + 2; + for (int step = 0; step < budget; step += 1) { + if (index >= node_count) { + break; + } + int node_slot = SCENE_SLOTS + index * NODE_SLOTS; + vec4 lower_bound = fetch_scene(node_slot); + vec4 upper_bound = fetch_scene(node_slot + 1); + float escape = fetch_scene(node_slot + 2).x; + int count = int(upper_bound.w + 0.5); + bool overlap = node_overlaps( + origin, + dir, + t_min, + t_max, + lower_bound.xyz, + upper_bound.xyz + ); + if (!overlap) { + index = int(escape + 0.5); + continue; + } + if (count == 0) { + index += 1; + continue; + } + int start = int(lower_bound.w + 0.5); + for (int offset = 0; offset < count; offset += 1) { + int slot = primitive_offset + (start + offset) * PRIMITIVE_SLOTS; + float hit_t; + vec3 hit_normal; + if (!primitive_hit(slot, origin, dir, t_min, t_max, hit_t, hit_normal)) { + continue; + } + float original_index = fetch_scene(slot + 2).w; + if (best.found) { + if (best.t < hit_t) { + continue; + } + if (best.t == hit_t && best.original_index <= original_index) { + continue; + } + } + vec4 albedo = fetch_scene(slot + 5); + vec4 emissive = fetch_scene(slot + 6); + best.found = true; + best.t = hit_t; + best.position = origin + dir * hit_t; + best.normal = hit_normal; + best.albedo = albedo.xyz; + best.specular = albedo.w; + best.emissive = emissive.xyz; + best.shininess = emissive.w; + best.material_kind = int(fetch_scene(slot + 1).w + 0.5); + best.original_index = original_index; + } + index = int(escape + 0.5); + } + return best; +} + +// The shadow ray starts offset along the surface normal and stops short of +// the light, so neither the surface it starts on nor the light itself can +// register as an occluder. +bool occluded(vec3 origin, vec3 normal, vec3 dir, float max_distance) { + vec3 start = origin + normal * SURFACE_OFFSET; + float clipped = max(max_distance - SURFACE_OFFSET, T_MIN_SECONDARY); + Hit shadow = closest_hit(start, dir, T_MIN_SECONDARY, clipped); + return shadow.found; +} + +vec3 lambert_term( + vec3 light_color, + float intensity, + vec3 light_dir, + vec3 normal, + vec3 albedo +) { + float cos_term = max(dot(normal, light_dir), 0.0); + float k = intensity * cos_term; + return vec3( + light_color.x * albedo.x * k, + light_color.y * albedo.y * k, + light_color.z * albedo.z * k + ); +} + +// The Phong lobe reflects the *incident* ray -L about N, not the Blinn +// half-vector. Reflecting L instead points back toward the light source, +// so the highlight would land on the unlit side. +float phong_factor(vec3 light_dir, vec3 normal, vec3 view_dir, float shininess) { + if (dot(normal, light_dir) <= 0.0) { + return 0.0; + } + vec3 reflected = safe_normalize(normal * (2.0 * dot(normal, light_dir)) - light_dir); + return pow(max(dot(reflected, view_dir), 0.0), shininess); +} + +float schlick_fresnel(vec3 light_dir, vec3 view_dir, vec3 normal) { + vec3 half_vector = light_dir + view_dir; + float half_len = length(half_vector); + float cos_term = half_len > EPS ? clamp(abs(dot(normal, half_vector / half_len)), 0.0, 1.0) : 1.0; + return PBR_BASE + (1.0 - PBR_BASE) * pow(1.0 - cos_term, 5.0); +} + +vec3 shade_hit(Hit hit, vec4 lighting, vec4 ambient, int light_count) { + vec3 position = hit.position; + vec3 normal = hit.normal; + vec3 view_dir = safe_normalize(lighting.xyz - position); + vec3 color = ambient.xyz; + for (int i = 0; i < light_count; i += 1) { + int base = FRAME_HEADER_SLOTS + i * LIGHT_SLOTS; + vec4 head = fetch_frame(base); + vec4 direction = fetch_frame(base + 1); + vec4 color_record = fetch_frame(base + 2); + vec4 cone = fetch_frame(base + 3); + int kind = int(head.w + 0.5); + vec3 light_color = color_record.xyz; + float raw_intensity = direction.w; + if (raw_intensity <= 0.0) { + continue; + } + vec3 light_dir; + float distance; + if (kind == 0) { + light_dir = direction.xyz; + distance = 0.0; + } else { + vec3 to_light = head.xyz - position; + distance = length(to_light); + light_dir = distance > EPS ? to_light / distance : vec3(0.0); + } + if (kind == 2) { + if (light_dir != vec3(0.0) && dot(light_dir, direction.xyz) > -cone.x) { + continue; + } + } + if (dot(normal, light_dir) <= 0.0) { + continue; + } + float falloff_factor = 1.0; + float shadow_distance = T_MAX_SECONDARY; + if (kind != 0) { + // Falloff uses the clamped distance so a surface sitting on the + // lamp cannot divide by zero, but the shadow ray must reach the + // ACTUAL lamp, so it keeps the unclamped distance. + float dist = max(distance, T_MIN_SECONDARY); + float denominator = 1.0 + color_record.w * dist * dist; + falloff_factor = max(1.0 / denominator, 0.0); + shadow_distance = distance; + } + float shadow = occluded(position, normal, light_dir, shadow_distance) ? 0.0 : 1.0; + if (shadow <= 0.0) { + continue; + } + float intensity = raw_intensity * falloff_factor; + vec3 diffuse = lambert_term(light_color, intensity, light_dir, normal, hit.albedo); + vec3 contribution = diffuse; + if (hit.material_kind == 2) { + float fresnel = schlick_fresnel(light_dir, view_dir, normal); + contribution = diffuse * (PBR_BASE + (1.0 - PBR_BASE) * fresnel); + } else if (hit.material_kind == 1) { + float factor = phong_factor(light_dir, normal, view_dir, hit.shininess); + float k = intensity * factor * hit.specular; + contribution = diffuse + light_color * k; + } + color += contribution * shadow; + } + return color + hit.emissive; +} + +vec3 trace(vec3 origin, vec3 dir, vec4 lighting, vec4 ambient, int light_count, float max_bounces, float near_t, float far_t) { + vec3 color = vec3(0.0); + float throughput = 1.0; + float t_min = near_t; + float t_max = far_t; + int depth = 0; + for (int bounce = 0; bounce <= 64; bounce += 1) { + Hit hit = closest_hit(origin, dir, t_min, t_max); + if (!hit.found) { + return color + ambient.xyz * throughput; + } + color += shade_hit(hit, lighting, ambient, light_count) * throughput; + if (depth >= int(max_bounces + 0.5) || hit.specular <= EPS) { + return color; + } + throughput *= hit.specular; + dir = normalize(dir - hit.normal * (2.0 * dot(dir, hit.normal))); + origin = hit.position; + t_min = T_MIN_SECONDARY; + t_max = T_MAX_SECONDARY; + depth += 1; + } + return color; +} + +void main() { + vec4 eye_record = fetch_frame(0); + vec4 projection = fetch_frame(1); + vec4 right_record = fetch_frame(2); + vec4 up_record = fetch_frame(3); + vec4 ambient = fetch_frame(4); + vec4 lighting = fetch_frame(5); + vec4 range = fetch_frame(6); + int light_count = int(ambient.w + 0.5); + float max_bounces = lighting.w; + float samples = max(range.z, 1.0); + float aspect = projection.w; + vec2 resolution = vec2(right_record.w, up_record.w); + int sample_count = int(samples + 0.5); + vec3 accumulated = vec3(0.0); + for (int sy = 0; sy < sample_count; sy += 1) { + for (int sx = 0; sx < sample_count; sx += 1) { + // gl_FragCoord is bottom-up; mapping it straight onto ndc_y + // keeps the image upright and matches the CPU reference. + // CPU reference renderer walks. + float px = floor(gl_FragCoord.x) + (float(sx) + 0.5) / samples; + float py = floor(gl_FragCoord.y) + (float(sy) + 0.5) / samples; + float ndc_x = (px / resolution.x) * 2.0 - 1.0; + float ndc_y = (py / resolution.y) * 2.0 - 1.0; + // tan(fov/2) is what makes the vertical field of view exactly + // `fov`; omitting it widens the image past the camera's ask. + float tan_half = eye_record.w; + vec3 dir = normalize( + projection.xyz + + right_record.xyz * (ndc_x * aspect * tan_half) + + up_record.xyz * (ndc_y * tan_half) + ); + accumulated += trace( + eye_record.xyz, + dir, + lighting, + ambient, + light_count, + max_bounces, + range.x, + range.y + ); + } + } + vec3 linear = accumulated / (samples * samples); + vec3 gamma = pow(clamp(linear, vec3(0.0), vec3(1.0)), vec3(1.0 / GAMMA)); + out_color = vec4(gamma, 1.0); +} +"#; diff --git a/engine/src/raytracing/pipeline/shader/glsl/mod.rs b/engine/src/raytracing/pipeline/shader/glsl/mod.rs new file mode 100644 index 00000000..1c7d0d10 --- /dev/null +++ b/engine/src/raytracing/pipeline/shader/glsl/mod.rs @@ -0,0 +1,3 @@ +mod r#const; + +pub(crate) use r#const::*; diff --git a/engine/src/raytracing/pipeline/shader/mod.rs b/engine/src/raytracing/pipeline/shader/mod.rs new file mode 100644 index 00000000..be7ead5e --- /dev/null +++ b/engine/src/raytracing/pipeline/shader/mod.rs @@ -0,0 +1,7 @@ +mod r#glsl; +mod r#source; +mod r#wgsl; + +pub use r#source::*; + +pub(crate) use {r#glsl::*, r#wgsl::*}; diff --git a/engine/src/raytracing/pipeline/shader/source/fn.rs b/engine/src/raytracing/pipeline/shader/source/fn.rs new file mode 100644 index 00000000..9921aae1 --- /dev/null +++ b/engine/src/raytracing/pipeline/shader/source/fn.rs @@ -0,0 +1,29 @@ +use super::*; + +/// Returns the GLSL ES 3.00 vertex shader source for the WebGL2 backend. +/// +/// # Returns +/// +/// - `&'static str` - The vertex shader source. +pub fn raytrace_glsl_vertex() -> &'static str { + RAYTRACE_GLSL_VERTEX +} + +/// Returns the GLSL ES 3.00 fragment shader source for the WebGL2 +/// backend. +/// +/// # Returns +/// +/// - `&'static str` - The fragment shader source. +pub fn raytrace_glsl_fragment() -> &'static str { + RAYTRACE_GLSL_FRAGMENT +} + +/// Returns the WGSL shader source for the WebGPU backend. +/// +/// # Returns +/// +/// - `&'static str` - The combined vertex and fragment shader source. +pub fn raytrace_wgsl() -> &'static str { + RAYTRACE_WGSL +} diff --git a/engine/src/raytracing/pipeline/shader/source/mod.rs b/engine/src/raytracing/pipeline/shader/source/mod.rs new file mode 100644 index 00000000..7087a15a --- /dev/null +++ b/engine/src/raytracing/pipeline/shader/source/mod.rs @@ -0,0 +1,5 @@ +mod r#fn; + +pub use r#fn::*; + +use super::*; diff --git a/engine/src/raytracing/pipeline/shader/wgsl/const.rs b/engine/src/raytracing/pipeline/shader/wgsl/const.rs new file mode 100644 index 00000000..0bfdd3dd --- /dev/null +++ b/engine/src/raytracing/pipeline/shader/wgsl/const.rs @@ -0,0 +1,632 @@ +/// The WGSL shader for the WebGPU ray-tracing backend. +/// +/// It reads the same packed scene and packed frame buffers the GLSL backend +/// and the CPU reference renderer read, uploaded as two read-only storage +/// buffers. The packed array is addressed as `vec4` slots by index. +/// +/// Nothing about the scene is baked into the source: the node count, the +/// primitive count, the light count, the sample grid, the bounce limit and +/// the camera all arrive through the two bindings, so one pipeline serves +/// any scene. Every buffer read is range-checked against +/// `arrayLength`, so a short or malformed upload cannot produce an +/// out-of-bounds access. +/// +/// This is a software BVH traversal executed in a fragment shader, not +/// hardware ray tracing: the WebGPU standard exposes no hardware +/// ray-tracing API. +pub(crate) const RAYTRACE_WGSL: &str = r#" +@group(0) @binding(0) var scene_data: array>; +@group(0) @binding(1) var frame_data: array>; + +const SCENE_SLOTS: u32 = 1u; +const NODE_SLOTS: u32 = 3u; +const PRIMITIVE_SLOTS: u32 = 8u; +const FRAME_HEADER_SLOTS: u32 = 8u; +const LIGHT_SLOTS: u32 = 4u; + +const EPS: f32 = 1e-6; +const T_MIN_SECONDARY: f32 = 0.001; +const T_MAX_SECONDARY: f32 = 1000.0; +const SURFACE_OFFSET: f32 = 1e-3; +const PBR_BASE: f32 = 0.04; +const GAMMA: f32 = 2.2; + +struct VertexOutput { + @builtin(position) position: vec4, +} + +struct Hit { + found: bool, + t: f32, + position: vec3, + normal: vec3, + specular: f32, + shininess: f32, + albedo: vec3, + emissive: vec3, + material_kind: u32, + original_index: f32, +} + +struct Slab { + hit: bool, + enter: f32, + exit: f32, +} + +fn scene_slot(slot: u32) -> vec4 { + if (slot >= arrayLength(&scene_data)) { + return vec4(0.0, 0.0, 0.0, 0.0); + } + return scene_data[slot]; +} + +fn frame_slot(slot: u32) -> vec4 { + if (slot >= arrayLength(&frame_data)) { + return vec4(0.0, 0.0, 0.0, 0.0); + } + return frame_data[slot]; +} + +fn safe_normalize(v: vec3) -> vec3 { + let len: f32 = length(v); + if (len <= EPS) { + return vec3(0.0, 0.0, 0.0); + } + return v / len; +} + +// Mirrors the engine slab test: a ray parallel to a slab and outside it +// rejects, and one inside it imposes no constraint. +fn slab(o: f32, d: f32, lo: f32, hi: f32) -> Slab { + var out: Slab; + if (abs(d) < EPS) { + if (o < lo || o > hi) { + out.hit = false; + out.enter = 0.0; + out.exit = 0.0; + return out; + } + out.hit = true; + out.enter = -1.0e30; + out.exit = 1.0e30; + return out; + } + let t0: f32 = (lo - o) / d; + let t1: f32 = (hi - o) / d; + out.hit = true; + out.enter = min(t0, t1); + out.exit = max(t0, t1); + return out; +} + +fn node_overlaps( + origin: vec3, + dir: vec3, + t_min: f32, + t_max: f32, + lower: vec3, + upper: vec3 +) -> bool { + let sx: Slab = slab(origin.x, dir.x, lower.x, upper.x); + if (!sx.hit) { + return false; + } + let sy: Slab = slab(origin.y, dir.y, lower.y, upper.y); + if (!sy.hit) { + return false; + } + let sz: Slab = slab(origin.z, dir.z, lower.z, upper.z); + if (!sz.hit) { + return false; + } + let t_enter: f32 = max(max(max(sx.enter, sy.enter), sz.enter), t_min); + let t_exit: f32 = min(min(min(sx.exit, sy.exit), sz.exit), t_max); + return t_enter <= t_exit; +} + +// Two-sided Moller-Trumbore, matching the engine intersect_triangle. +fn triangle_hit( + origin: vec3, + dir: vec3, + v0: vec3, + v1: vec3, + v2: vec3, + t_min: f32, + t_max: f32 +) -> Hit { + var out: Hit; + out.found = false; + out.t = 0.0; + out.position = vec3(0.0, 0.0, 0.0); + out.normal = vec3(0.0, 1.0, 0.0); + out.specular = 0.0; + out.shininess = 0.0; + out.albedo = vec3(0.0, 0.0, 0.0); + out.emissive = vec3(0.0, 0.0, 0.0); + out.material_kind = 0u; + out.original_index = 0.0; + let edge1: vec3 = v1 - v0; + let edge2: vec3 = v2 - v0; + let pvec: vec3 = cross(dir, edge2); + let det: f32 = dot(edge1, pvec); + if (abs(det) < EPS) { + return out; + } + let inverse_det: f32 = 1.0 / det; + let tvec: vec3 = origin - v0; + let u: f32 = dot(tvec, pvec) * inverse_det; + if (u < 0.0 || u > 1.0) { + return out; + } + let qvec: vec3 = cross(tvec, edge1); + let v: f32 = dot(dir, qvec) * inverse_det; + if (v < 0.0 || u + v > 1.0) { + return out; + } + let t: f32 = dot(edge2, qvec) * inverse_det; + if (t < t_min || t > t_max) { + return out; + } + let geometric: vec3 = safe_normalize(cross(edge1, edge2)); + let normal: vec3 = select(geometric, -geometric, dot(geometric, dir) > 0.0); + out.found = true; + out.t = t; + out.position = origin + dir * t; + out.normal = normal; + out.specular = 0.0; + out.shininess = 0.0; + out.albedo = vec3(0.0, 0.0, 0.0); + out.emissive = vec3(0.0, 0.0, 0.0); + out.material_kind = 0u; + out.original_index = 0.0; + return out; +} + +// The quadratic form resolves both roots, so a ray starting inside the +// solid exits rather than reporting a behind-the-origin miss. The +// direction need not be unit length: the `a` term keeps the roots correct. +fn primitive_hit( + slot: u32, + origin: vec3, + dir: vec3, + t_min: f32, + t_max: f32 +) -> Hit { + var out: Hit; + out.found = false; + out.t = 0.0; + out.position = vec3(0.0, 0.0, 0.0); + out.normal = vec3(0.0, 1.0, 0.0); + out.specular = 0.0; + out.shininess = 0.0; + out.albedo = vec3(0.0, 0.0, 0.0); + out.emissive = vec3(0.0, 0.0, 0.0); + out.material_kind = 0u; + out.original_index = 0.0; + if (slot + PRIMITIVE_SLOTS > arrayLength(&scene_data)) { + return out; + } + let shape: vec4 = scene_data[slot]; + let extent: vec4 = scene_data[slot + 1u]; + let vertex0: vec4 = scene_data[slot + 2u]; + let vertex1: vec4 = scene_data[slot + 3u]; + let vertex2: vec4 = scene_data[slot + 4u]; + let albedo: vec4 = scene_data[slot + 5u]; + let emissive: vec4 = scene_data[slot + 6u]; + let kind: u32 = u32(shape.w + 0.5); + if (kind == 2u) { + var tri: Hit = triangle_hit(origin, dir, vertex0.xyz, vertex1.xyz, vertex2.xyz, t_min, t_max); + tri.albedo = albedo.xyz; + tri.specular = albedo.w; + tri.emissive = emissive.xyz; + tri.shininess = emissive.w; + tri.material_kind = u32(extent.w + 0.5); + tri.original_index = vertex0.w; + return tri; + } + if (kind == 1u) { + let sx: Slab = slab(origin.x, dir.x, shape.x, extent.x); + if (!sx.hit) { + return out; + } + let sy: Slab = slab(origin.y, dir.y, shape.y, extent.y); + if (!sy.hit) { + return out; + } + let sz: Slab = slab(origin.z, dir.z, shape.z, extent.z); + if (!sz.hit) { + return out; + } + let t_near: f32 = max(max(sx.enter, sy.enter), sz.enter); + let t_far: f32 = min(min(sx.exit, sy.exit), sz.exit); + if (t_near > t_far || t_far < t_min) { + return out; + } + var hit_t: f32 = t_near; + if (hit_t < t_min) { + hit_t = t_far; + } + if (hit_t < t_min || hit_t > t_max) { + return out; + } + let hit: vec3 = origin + dir * hit_t; + let center: vec3 = (shape.xyz + extent.xyz) * 0.5; + let delta: vec3 = hit - center; + let half_extent: vec3 = (extent.xyz - shape.xyz) * 0.5; + let scaled: vec3 = abs(delta) / max(half_extent, vec3(EPS)); + var normal: vec3 = vec3(0.0, 0.0, sign(delta.z)); + if (scaled.y >= scaled.z) { + normal = vec3(0.0, sign(delta.y), 0.0); + } + if (scaled.x >= scaled.y && scaled.x >= scaled.z) { + normal = vec3(sign(delta.x), 0.0, 0.0); + } + out.found = true; + out.t = hit_t; + out.position = hit; + out.normal = normal; + out.albedo = albedo.xyz; + out.specular = albedo.w; + out.emissive = emissive.xyz; + out.shininess = emissive.w; + out.material_kind = u32(extent.w + 0.5); + out.original_index = vertex0.w; + return out; + } + let oc: vec3 = origin - shape.xyz; + let a: f32 = dot(dir, dir); + if (a <= EPS) { + return out; + } + let b: f32 = dot(oc, dir); + let c: f32 = dot(oc, oc) - extent.x * extent.x; + let disc: f32 = b * b - a * c; + if (disc < 0.0) { + return out; + } + let sq: f32 = sqrt(disc); + let near_t: f32 = (-b - sq) / a; + let far_t: f32 = (-b + sq) / a; + var hit_t: f32 = near_t; + if (hit_t < t_min) { + hit_t = far_t; + } + if (hit_t < t_min || hit_t > t_max) { + return out; + } + out.found = true; + out.t = hit_t; + out.position = origin + dir * hit_t; + out.normal = safe_normalize(origin + dir * hit_t - shape.xyz); + out.albedo = albedo.xyz; + out.specular = albedo.w; + out.emissive = emissive.xyz; + out.shininess = emissive.w; + out.material_kind = u32(extent.w + 0.5); + out.original_index = vertex0.w; + return out; +} + +// Ordered traversal over the packed preorder node array: an internal-node +// hit visits index + 1, a miss or a leaf jumps to escape. The iteration is +// bounded by node_count so a malformed buffer cannot hang the shader, and +// ties on t resolve to the lowest original primitive index. +fn closest_hit( + origin: vec3, + dir: vec3, + t_min: f32, + t_max: f32 +) -> Hit { + var best: Hit; + best.found = false; + best.t = 0.0; + best.position = vec3(0.0, 0.0, 0.0); + best.normal = vec3(0.0, 1.0, 0.0); + best.specular = 0.0; + best.shininess = 0.0; + best.albedo = vec3(0.0, 0.0, 0.0); + best.emissive = vec3(0.0, 0.0, 0.0); + best.material_kind = 0u; + best.original_index = 0.0; + let header: vec4 = scene_slot(0u); + let node_count: u32 = u32(header.x + 0.5); + let primitive_count: u32 = u32(header.y + 0.5); + let primitive_offset: u32 = u32(header.z + 0.5); + if (node_count == 0u || primitive_count == 0u) { + return best; + } + var index: u32 = 0u; + let budget: u32 = node_count * 2u + 2u; + var step: u32 = 0u; + loop { + if (step >= budget) { + break; + } + step = step + 1u; + if (index >= node_count) { + break; + } + let node_slot: u32 = SCENE_SLOTS + index * NODE_SLOTS; + if (node_slot + NODE_SLOTS > arrayLength(&scene_data)) { + break; + } + let lower_bound: vec4 = scene_data[node_slot]; + let upper_bound: vec4 = scene_data[node_slot + 1u]; + let escape: u32 = u32(scene_data[node_slot + 2u].x + 0.5); + let count: u32 = u32(upper_bound.w + 0.5); + let overlap: bool = node_overlaps( + origin, + dir, + t_min, + t_max, + lower_bound.xyz, + upper_bound.xyz + ); + if (!overlap) { + index = escape; + continue; + } + if (count == 0u) { + index = index + 1u; + continue; + } + let start: u32 = u32(lower_bound.w + 0.5); + for (var offset: u32 = 0u; offset < count; offset = offset + 1u) { + let slot: u32 = primitive_offset + (start + offset) * PRIMITIVE_SLOTS; + let candidate: Hit = primitive_hit(slot, origin, dir, t_min, t_max); + if (!candidate.found) { + continue; + } + if (best.found) { + if (best.t < candidate.t) { + continue; + } + if (best.t == candidate.t && best.original_index <= candidate.original_index) { + continue; + } + } + best = candidate; + } + index = escape; + } + return best; +} + +// The shadow ray starts offset along the surface normal and stops short of +// the light, so neither the surface it starts on nor the light itself can +// register as an occluder. +fn occluded( + origin: vec3, + normal: vec3, + dir: vec3, + max_distance: f32 +) -> bool { + let start: vec3 = origin + normal * SURFACE_OFFSET; + let clipped: f32 = max(max_distance - SURFACE_OFFSET, T_MIN_SECONDARY); + let shadow: Hit = closest_hit(start, dir, T_MIN_SECONDARY, clipped); + return shadow.found; +} + +fn lambert_term( + light_color: vec3, + intensity: f32, + light_dir: vec3, + normal: vec3, + albedo: vec3 +) -> vec3 { + let cos_term: f32 = max(dot(normal, light_dir), 0.0); + let k: f32 = intensity * cos_term; + return vec3( + light_color.x * albedo.x * k, + light_color.y * albedo.y * k, + light_color.z * albedo.z * k + ); +} + +// The Phong lobe reflects the *incident* ray -L about N, not the Blinn +// half-vector. Reflecting L instead points back toward the light source, so +// the highlight would land on the unlit side. +fn phong_factor( + light_dir: vec3, + normal: vec3, + view_dir: vec3, + shininess: f32 +) -> f32 { + if (dot(normal, light_dir) <= 0.0) { + return 0.0; + } + let reflected: vec3 = safe_normalize(normal * (2.0 * dot(normal, light_dir)) - light_dir); + return pow(max(dot(reflected, view_dir), 0.0), shininess); +} + +fn schlick_fresnel(light_dir: vec3, view_dir: vec3, normal: vec3) -> f32 { + let half_vector: vec3 = light_dir + view_dir; + let half_len: f32 = length(half_vector); + var cos_term: f32 = 1.0; + if (half_len > EPS) { + cos_term = clamp(abs(dot(normal, half_vector / half_len)), 0.0, 1.0); + } + return PBR_BASE + (1.0 - PBR_BASE) * pow(1.0 - cos_term, 5.0); +} + +fn shade_hit( + hit: Hit, + lighting: vec4, + ambient: vec4, + light_count: u32 +) -> vec3 { + let position: vec3 = hit.position; + let normal: vec3 = hit.normal; + let view_dir: vec3 = safe_normalize(lighting.xyz - position); + var color: vec3 = ambient.xyz; + for (var i: u32 = 0u; i < light_count; i = i + 1u) { + let base: u32 = FRAME_HEADER_SLOTS + i * LIGHT_SLOTS; + if (base + LIGHT_SLOTS > arrayLength(&frame_data)) { + break; + } + let head: vec4 = frame_data[base]; + let direction: vec4 = frame_data[base + 1u]; + let color_record: vec4 = frame_data[base + 2u]; + let cone: vec4 = frame_data[base + 3u]; + let kind: u32 = u32(head.w + 0.5); + let light_color: vec3 = color_record.xyz; + let raw_intensity: f32 = direction.w; + if (raw_intensity <= 0.0) { + continue; + } + var light_dir: vec3 = direction.xyz; + var distance: f32 = 0.0; + if (kind != 0u) { + let to_light: vec3 = head.xyz - position; + distance = length(to_light); + if (distance > EPS) { + light_dir = to_light / distance; + } else { + light_dir = vec3(0.0, 0.0, 0.0); + } + } + if (kind == 2u) { + let degenerate: bool = light_dir.x == 0.0 && light_dir.y == 0.0 && light_dir.z == 0.0; + if (!degenerate && dot(light_dir, direction.xyz) > -cone.x) { + continue; + } + } + if (dot(normal, light_dir) <= 0.0) { + continue; + } + var falloff_factor: f32 = 1.0; + var shadow_distance: f32 = T_MAX_SECONDARY; + if (kind != 0u) { + // Falloff uses the clamped distance so a surface sitting on the + // lamp cannot divide by zero, but the shadow ray must reach the + // ACTUAL lamp, so it keeps the unclamped distance. + let dist: f32 = max(distance, T_MIN_SECONDARY); + let denominator: f32 = 1.0 + color_record.w * dist * dist; + falloff_factor = max(1.0 / denominator, 0.0); + shadow_distance = distance; + } + var shadow: f32 = 1.0; + if (occluded(position, normal, light_dir, shadow_distance)) { + shadow = 0.0; + } + if (shadow <= 0.0) { + continue; + } + let intensity: f32 = raw_intensity * falloff_factor; + let diffuse: vec3 = lambert_term(light_color, intensity, light_dir, normal, hit.albedo); + var contribution: vec3 = diffuse; + if (hit.material_kind == 2u) { + let fresnel: f32 = schlick_fresnel(light_dir, view_dir, normal); + contribution = diffuse * (PBR_BASE + (1.0 - PBR_BASE) * fresnel); + } else if (hit.material_kind == 1u) { + let factor: f32 = phong_factor(light_dir, normal, view_dir, hit.shininess); + let k: f32 = intensity * factor * hit.specular; + contribution = diffuse + light_color * k; + } + color = color + contribution * shadow; + } + return color + hit.emissive; +} + +fn trace( + origin: vec3, + dir: vec3, + lighting: vec4, + ambient: vec4, + light_count: u32, + max_bounces: u32, + near_t: f32, + far_t: f32 +) -> vec3 { + var color: vec3 = vec3(0.0, 0.0, 0.0); + var throughput: f32 = 1.0; + var ray_origin: vec3 = origin; + var ray_dir: vec3 = dir; + var t_min: f32 = near_t; + var t_max: f32 = far_t; + var depth: u32 = 0u; + loop { + let hit: Hit = closest_hit(ray_origin, ray_dir, t_min, t_max); + if (!hit.found) { + return color + ambient.xyz * throughput; + } + color = color + shade_hit(hit, lighting, ambient, light_count) * throughput; + if (depth >= max_bounces || hit.specular <= EPS) { + return color; + } + throughput = throughput * hit.specular; + ray_dir = safe_normalize(ray_dir - hit.normal * (2.0 * dot(ray_dir, hit.normal))); + ray_origin = hit.position; + t_min = T_MIN_SECONDARY; + t_max = T_MAX_SECONDARY; + depth = depth + 1u; + if (depth > 64u) { + return color; + } + } +} + +@vertex +fn vs_main(@builtin(vertex_index) vertex_index: u32) -> VertexOutput { + var out: VertexOutput; + var positions: array, 3> = array, 3>( + vec2(-1.0, -1.0), + vec2(3.0, -1.0), + vec2(-1.0, 3.0) + ); + let position: vec2 = positions[vertex_index]; + out.position = vec4(position.x, position.y, 0.0, 1.0); + return out; +} + +@fragment +fn fs_main(input: VertexOutput) -> @location(0) vec4 { + let eye_record: vec4 = frame_slot(0u); + let projection: vec4 = frame_slot(1u); + let right_record: vec4 = frame_slot(2u); + let up_record: vec4 = frame_slot(3u); + let ambient: vec4 = frame_slot(4u); + let lighting: vec4 = frame_slot(5u); + let range: vec4 = frame_slot(6u); + let light_count: u32 = u32(ambient.w + 0.5); + let max_bounces: u32 = u32(lighting.w + 0.5); + let samples: f32 = max(range.z, 1.0); + let sample_count: u32 = u32(samples + 0.5); + let aspect: f32 = projection.w; + let resolution: vec2 = vec2(right_record.w, up_record.w); + let pixel: vec2 = input.position.xy; + var accumulated: vec3 = vec3(0.0, 0.0, 0.0); + for (var sy: u32 = 0u; sy < sample_count; sy = sy + 1u) { + for (var sx: u32 = 0u; sx < sample_count; sx = sx + 1u) { + // The fragment position is top-down, matching the CPU reference + // renderer's row order directly. + let px: f32 = floor(pixel.x) + (f32(sx) + 0.5) / samples; + let py: f32 = floor(pixel.y) + (f32(sy) + 0.5) / samples; + let ndc_x: f32 = (px / resolution.x) * 2.0 - 1.0; + let ndc_y: f32 = 1.0 - (py / resolution.y) * 2.0; + // tan(fov/2) is what makes the vertical field of view exactly + // `fov`; omitting it widens the image past the camera's ask. + let tan_half: f32 = eye_record.w; + let dir: vec3 = safe_normalize( + projection.xyz + + right_record.xyz * (ndc_x * aspect * tan_half) + + up_record.xyz * (ndc_y * tan_half) + ); + accumulated = accumulated + trace( + eye_record.xyz, + dir, + lighting, + ambient, + light_count, + max_bounces, + range.x, + range.y + ); + } + } + let linear: vec3 = accumulated / (samples * samples); + let gamma: vec3 = pow(clamp(linear, vec3(0.0, 0.0, 0.0), vec3(1.0, 1.0, 1.0)), vec3(1.0 / GAMMA)); + return vec4(gamma, 1.0); +} +"#; diff --git a/engine/src/raytracing/pipeline/shader/wgsl/mod.rs b/engine/src/raytracing/pipeline/shader/wgsl/mod.rs new file mode 100644 index 00000000..1c7d0d10 --- /dev/null +++ b/engine/src/raytracing/pipeline/shader/wgsl/mod.rs @@ -0,0 +1,3 @@ +mod r#const; + +pub(crate) use r#const::*; diff --git a/engine/src/raytracing/scene/const.rs b/engine/src/raytracing/scene/const.rs new file mode 100644 index 00000000..438471c2 --- /dev/null +++ b/engine/src/raytracing/scene/const.rs @@ -0,0 +1,72 @@ +/// The default maximum number of bounces evaluated by +/// [`RayTraceScene::trace`](crate::RayTraceScene::trace) before returning. Higher values yield more +/// accurate reflections at increased computational cost. +pub(crate) const RAYTRACE_DEFAULT_MAX_BOUNCES: u32 = 4; + +/// The minimum ray parameter `t` accepted as a valid intersection. Hits +/// closer than this are treated as self-intersection artifacts and skipped. +pub(crate) const RAYTRACE_DEFAULT_T_MIN: f64 = 0.001; + +/// The maximum ray parameter `t` evaluated before a ray is considered to +/// have escaped the scene. Pairs with `RAYTRACE_DEFAULT_T_MIN`. +pub(crate) const RAYTRACE_DEFAULT_T_MAX: f64 = 1000.0; + +/// The Moller-Trumbore determinant magnitude below which a ray is treated as +/// parallel to the triangle plane and therefore unable to hit it. Pairs with +/// the shared [`EPSILON`](crate::EPSILON) tolerance. +pub(crate) const RAYTRACE_TRIANGLE_EPSILON: f64 = 1e-8; + +/// The maximum number of primitives a single BVH leaf may hold before the +/// builder splits it further. A small leaf keeps the exact per-primitive +/// tests visible in profiles; a larger value trades that for shorter +/// traversal sequences on uniform scenes. +pub(crate) const RAYTRACE_BVH_LEAF_SIZE: usize = 4; + +/// The fixed traversal stack size, sized so a hierarchy can never outgrow +/// it. +/// +/// Traversal pushes at most one extra entry per internal node it pops, so +/// the pending stack never exceeds `RAYTRACE_BVH_MAX_DEPTH + 1` entries. +/// Sizing the array well above that bound keeps the stack a plain array on +/// the caller's frame, which is what makes a query allocation free. +pub(crate) const RAYTRACE_BVH_STACK_SIZE: usize = 64; + +/// The deepest a BVH subtree may nest before the builder stops splitting. +/// +/// The surface-area heuristic deliberately does not guarantee a balanced +/// tree: on a geometric spread it can keep choosing to peel off a single +/// cheap primitive and chain linearly, so depth is **not** bounded by +/// `log2(n)`. A linear chain would overflow the fixed traversal stack and +/// silently drop the near child of every node below the overflow, which +/// loses real geometry rather than merely costing time. Capping the build +/// depth here and emitting a leaf at the cap bounds the hierarchy by +/// construction; a leaf only ever tests more primitives, never fewer, so +/// the cap trades speed for a guarantee that no hit can be lost. +pub(crate) const RAYTRACE_BVH_MAX_DEPTH: usize = 48; + +/// The number of subdivisions each axis of a node's bounds is split into +/// while binning its primitives for the surface-area-heuristic cost. +pub(crate) const RAYTRACE_BVH_BINS: usize = 16; + +/// The largest scene that is traced by walking every primitive directly. +/// +/// Below this count the hierarchy cannot pay for itself: a build makes every +/// query pay one extra bounds test and one indirect hop per primitive, and +/// at a handful of primitives a straight scan is measurably cheaper. Above +/// it the scan's cost grows linearly while the traversal does not, so the +/// build is worth it. +pub(crate) const RAYTRACE_LINEAR_MAX_PRIMITIVES: usize = 4; + +/// The centroid axis must span more than this fraction of the widest +/// centroid axis before it is worth splitting on it, so flat or +/// coincident centroid sets fall back to a median split instead. +pub(crate) const RAYTRACE_BVH_AXIS_EPSILON: f64 = 1e-9; + +/// A shadow ray's origin is pushed this far along the surface normal +/// before it leaves the shading point, so a surface never shadows itself +/// through numerical slop at its own footprint. +pub(crate) const RAYTRACE_SHADOW_SELF_OFFSET: f64 = 0.001; + +/// A shadow segment stops this far short of a finite light position, so +/// geometry that merely encloses the emitter does not stay lit-in-shadow. +pub(crate) const RAYTRACE_SHADOW_LIGHT_EPSILON: f64 = 0.001; diff --git a/engine/src/raytracing/enum.rs b/engine/src/raytracing/scene/enum.rs similarity index 100% rename from engine/src/raytracing/enum.rs rename to engine/src/raytracing/scene/enum.rs diff --git a/engine/src/raytracing/scene/fn.rs b/engine/src/raytracing/scene/fn.rs new file mode 100644 index 00000000..b9cd0b64 --- /dev/null +++ b/engine/src/raytracing/scene/fn.rs @@ -0,0 +1,1417 @@ +use super::*; + +/// Returns the component-wise minimum of two vectors. +/// +/// # Arguments +/// +/// - `Vector3D` - The first vector. +/// - `Vector3D` - The second vector. +/// +/// # Returns +/// +/// - `Vector3D` - The component-wise minimum. +fn vector3d_min(a: Vector3D, b: Vector3D) -> Vector3D { + Vector3D::new( + a.get_x().min(b.get_x()), + a.get_y().min(b.get_y()), + a.get_z().min(b.get_z()), + ) +} + +/// Returns the component-wise maximum of two vectors. +/// +/// # Arguments +/// +/// - `Vector3D` - The first vector. +/// - `Vector3D` - The second vector. +/// +/// # Returns +/// +/// - `Vector3D` - The component-wise maximum. +fn vector3d_max(a: Vector3D, b: Vector3D) -> Vector3D { + Vector3D::new( + a.get_x().max(b.get_x()), + a.get_y().max(b.get_y()), + a.get_z().max(b.get_z()), + ) +} + +/// Reports whether every component of `value` is a finite number. +/// +/// Degenerate geometry and non-finite ray data must never register a hit, +/// and comparison operators silently accept `NaN` on both sides, so every +/// exact test filters its inputs and its resulting `t` through this +/// predicate instead of relying on `<` and `>` alone. +/// +/// # Arguments +/// +/// - `Vector3D` - The vector to inspect. +/// +/// # Returns +/// +/// - `bool` - `true` when no component is `NaN` or infinite. +fn is_finite_vector(value: Vector3D) -> bool { + value.get_x().is_finite() && value.get_y().is_finite() && value.get_z().is_finite() +} + +/// Returns the inclusive parameter interval of a single AABB slab along one +/// ray axis, or `None` when the ray runs parallel to the slab outside it. +/// +/// A parallel axis is not rejected outright: the interval becomes +/// `(-inf, +inf)` when the origin lies inside the slab and the caller keeps +/// only the two axes that actually constrain the ray. +/// +/// # Arguments +/// +/// - `f64` - The ray origin component on this axis. +/// - `f64` - The ray direction component on this axis. +/// - `f64` - The slab lower bound. +/// - `f64` - The slab upper bound. +/// +/// # Returns +/// +/// - `Option<(f64, f64)>` - The `(enter, exit)` parameters, or `None` when +/// the ray is parallel to the slab and outside it. +fn slab_interval( + origin_component: f64, + dir_component: f64, + lower: f64, + upper: f64, +) -> Option<(f64, f64)> { + if dir_component.abs() < RAYTRACE_TRIANGLE_EPSILON { + if origin_component < lower || origin_component > upper { + return None; + } + return Some((f64::NEG_INFINITY, f64::INFINITY)); + } + let inverse: f64 = 1.0 / dir_component; + let t0: f64 = (lower - origin_component) * inverse; + let t1: f64 = (upper - origin_component) * inverse; + Some((t0.min(t1), t0.max(t1))) +} + +/// Returns the clipped entry parameter of a ray segment against an AABB. +/// +/// The result is `None` when the segment provably cannot touch the box. +/// The interval is clipped to `t_min`..=`t_max`, so a traversal that +/// shrinks `t_max` as it discovers nearer hits also prunes the boxes +/// behind those hits for free. Parallel slabs are handled inside +/// [`slab_interval`], so an axis-aligned ray running along a face still +/// reports the other two axes' constraint instead of dividing by zero. +/// +/// # Arguments +/// +/// - `Vector3D` - The ray origin. +/// - `Vector3D` - The ray direction, not required to be unit length. +/// - `Vector3D` - The AABB minimum corner. +/// - `Vector3D` - The AABB maximum corner. +/// - `f64` - The minimum ray parameter considered a valid hit. +/// - `f64` - The maximum ray parameter considered a valid hit. +/// +/// # Returns +/// +/// - `Option` - The entry parameter clipped into the requested range, +/// or `None` when the segment misses the box. +fn aabb_entry_parameter( + origin: Vector3D, + dir: Vector3D, + lower: Vector3D, + upper: Vector3D, + t_min: f64, + t_max: f64, +) -> Option { + if !is_finite_vector(origin) + || !is_finite_vector(dir) + || !is_finite_vector(lower) + || !is_finite_vector(upper) + { + return None; + } + let x: Option<(f64, f64)> = + slab_interval(origin.get_x(), dir.get_x(), lower.get_x(), upper.get_x()); + let y: Option<(f64, f64)> = + slab_interval(origin.get_y(), dir.get_y(), lower.get_y(), upper.get_y()); + let z: Option<(f64, f64)> = + slab_interval(origin.get_z(), dir.get_z(), lower.get_z(), upper.get_z()); + let (x0, x1): (f64, f64) = x?; + let (y0, y1): (f64, f64) = y?; + let (z0, z1): (f64, f64) = z?; + let t_enter: f64 = x0.max(y0).max(z0); + let t_exit: f64 = x1.min(y1).min(z1); + if t_enter > t_exit { + return None; + } + let clipped: f64 = t_enter.max(t_min); + if clipped > t_exit.min(t_max) { + return None; + } + Some(clipped) +} + +/// Intersects a ray with the triangle `v0`, `v1`, `v2` using the +/// Moller-Trumbore algorithm. +/// +/// The test is two-sided: a hit on either face counts, and the returned +/// normal is flipped when needed so that it always faces against the ray +/// direction, making back-face hits usable for shading. A ray parallel to +/// the triangle plane yields `None`. No `t_min`/`t_max` filtering is +/// applied here; use [`Ray::intersect_triangle`] for the range-clamped +/// variant. +/// +/// # Arguments +/// +/// - `Vector3D` - The ray origin. +/// - `Vector3D` - The ray direction (expected to be unit length). +/// - `Vector3D` - The first triangle vertex. +/// - `Vector3D` - The second triangle vertex. +/// - `Vector3D` - The third triangle vertex. +/// +/// # Returns +/// +/// - `Option<(f64, Vector3D)>` - The hit distance along the ray and the +/// unit surface normal oriented against the ray direction, or `None` on +/// miss. +pub fn intersect_triangle( + origin: Vector3D, + dir: Vector3D, + v0: Vector3D, + v1: Vector3D, + v2: Vector3D, +) -> Option<(f64, Vector3D)> { + let edge1: Vector3D = v1 - v0; + let edge2: Vector3D = v2 - v0; + let pvec: Vector3D = dir.cross(edge2); + let det: f64 = edge1.dot(pvec); + if det.abs() < RAYTRACE_TRIANGLE_EPSILON { + return None; + } + let inverse_det: f64 = 1.0 / det; + let tvec: Vector3D = origin - v0; + let u: f64 = tvec.dot(pvec) * inverse_det; + if !(0.0..=1.0).contains(&u) { + return None; + } + let qvec: Vector3D = tvec.cross(edge1); + let v: f64 = dir.dot(qvec) * inverse_det; + // `is_finite` is the guard, not a `neg_cmp`: a NaN barycentric coordinate + // must fall through to the miss branch, and `!(x >= 0.0)` would also let a + // NaN through on the negation while losing that intent. + let v_usable: bool = v.is_finite() && (0.0..=1.0).contains(&v); + let edge_usable: bool = (u + v).is_finite() && (u + v <= 1.0); + if !v_usable || !edge_usable { + return None; + } + let t: f64 = edge2.dot(qvec) * inverse_det; + if !t.is_finite() { + return None; + } + let geometric: Vector3D = edge1.cross(edge2).normalized(); + let normal: Vector3D = if geometric.dot(dir) > 0.0 { + -geometric + } else { + geometric + }; + Some((t, normal)) +} + +/// Returns the AABB extents `(min, max)` of an [`Occluder`]. +/// +/// For AABB occluders this is `(center, extent)`. For sphere occluders +/// the bounding box is computed from the center and the `.x` component of +/// `extent` (the sphere radius). For triangle occluders the bounding box +/// is the component-wise span of the three vertices. +/// +/// # Arguments +/// +/// - `&Occluder` - The occluder to bound. +/// +/// # Returns +/// +/// - `(Vector3D, Vector3D)` - The `(min, max)` corners of the AABB. +fn occluder_aabb_extents(occluder: &Occluder) -> (Vector3D, Vector3D) { + let (mn, mx): (Vector3D, Vector3D) = match occluder.get_kind() { + OccluderKind::Aabb => (occluder.get_center(), occluder.get_extent()), + OccluderKind::Sphere => { + let center: Vector3D = occluder.get_center(); + let radius: f64 = occluder.get_extent().get_x(); + let r: Vector3D = Vector3D::new(radius, radius, radius); + (center - r, center + r) + } + OccluderKind::Triangle => { + let [v0, v1, v2]: [Vector3D; 3] = occluder.get_vertices(); + let lo: Vector3D = vector3d_min(v0, vector3d_min(v1, v2)); + let hi: Vector3D = vector3d_max(v0, vector3d_max(v1, v2)); + (lo, hi) + } + }; + (vector3d_min(mn, mx), vector3d_max(mn, mx)) +} + +/// Flattens every occluder into `(center, radius)` sphere tuples used by +/// [`soft_shadow_factor`]. +/// +/// For sphere occluders the tuple is `(center, radius)`. For AABB +/// occluders a conservative bounding sphere is computed from the AABB. +/// +/// # Arguments +/// +/// - `&[Occluder]` - The occluders to flatten into bounding spheres. +/// +/// # Returns +/// +/// - `Vec<(Vector3D, f64)>` - The `(center, radius)` sphere of each occluder. +pub(crate) fn collect_occluder_points(occluders: &[Occluder]) -> Vec<(Vector3D, f64)> { + let mut out: Vec<(Vector3D, f64)> = Vec::new(); + for occ in occluders.iter() { + let (mn, mx): (Vector3D, Vector3D) = occluder_aabb_extents(occ); + let cx: f64 = (mn.get_x() + mx.get_x()) * 0.5; + let cy: f64 = (mn.get_y() + mx.get_y()) * 0.5; + let cz: f64 = (mn.get_z() + mx.get_z()) * 0.5; + let ex: f64 = (mx.get_x() - mn.get_x()) * 0.5; + let ey: f64 = (mx.get_y() - mn.get_y()) * 0.5; + let ez: f64 = (mx.get_z() - mn.get_z()) * 0.5; + let r: f64 = (ex * ex + ey * ey + ez * ez).sqrt(); + out.push((Vector3D::new(cx, cy, cz), r)); + } + out +} + +/// Returns the surface area of an AABB, the cost metric behind the +/// surface-area-heuristic split search. +/// +/// # Arguments +/// +/// - `Vector3D` - The AABB minimum corner. +/// - `Vector3D` - The AABB maximum corner. +/// +/// # Returns +/// +/// - `f64` - Twice the surface area, clamped to zero for flat or inverted +/// bounds. +fn aabb_surface_area(lower: Vector3D, upper: Vector3D) -> f64 { + let dx: f64 = (upper.get_x() - lower.get_x()).max(0.0); + let dy: f64 = (upper.get_y() - lower.get_y()).max(0.0); + let dz: f64 = (upper.get_z() - lower.get_z()).max(0.0); + 2.0 * (dx * dy + dy * dz + dz * dx) +} + +/// Returns the component of `value` on the axis selected by `axis`. +/// +/// # Arguments +/// +/// - `Vector3D` - The vector to read. +/// - `usize` - `0` for `x`, `1` for `y`, `2` for `z`. +/// +/// # Returns +/// +/// - `f64` - The selected component. +fn axis_component(value: Vector3D, axis: usize) -> f64 { + match axis { + 0 => value.get_x(), + 1 => value.get_y(), + _ => value.get_z(), + } +} + +/// Intersects a ray with a sphere, honouring the ray's parameter range. +/// +/// Unlike the free function in `lighting`, this variant solves the +/// quadratic in general form so a non-unit direction reports true +/// distances, and it accepts the exit root when the origin is already +/// inside the sphere. A ray whose direction is zero, a negative radius, or +/// any `NaN` input never reports a hit. +/// +/// # Arguments +/// +/// - `Vector3D` - The ray origin. +/// - `Vector3D` - The ray direction, not required to be unit length. +/// - `Vector3D` - The sphere center. +/// - `f64` - The sphere radius. +/// - `f64` - The minimum acceptable hit parameter. +/// - `f64` - The maximum acceptable hit parameter. +/// +/// # Returns +/// +/// - `Option<(f64, Vector3D)>` - The hit parameter and the outward unit +/// normal, or `None` when the range-constrained ray misses. +fn intersect_sphere_in_range( + origin: Vector3D, + dir: Vector3D, + center: Vector3D, + radius: f64, + t_min: f64, + t_max: f64, +) -> Option<(f64, Vector3D)> { + // A zero or negative radius describes no surface at all, and the + // normalising step below would divide by its length, so reject both + // before any arithmetic. + if !radius.is_finite() || radius <= 0.0 { + return None; + } + if !is_finite_vector(origin) || !is_finite_vector(dir) || !is_finite_vector(center) { + return None; + } + let a: f64 = dir.dot(dir); + // A zero-length or non-finite direction carries no ray: a NaN `a` would + // divide every candidate by NaN, so the guard must reject it explicitly + // rather than relying on a negated comparison. + if !a.is_finite() || a <= 0.0 { + return None; + } + let oc: Vector3D = origin - center; + let half_b: f64 = oc.dot(dir); + let c: f64 = oc.dot(oc) - radius * radius; + let discriminant: f64 = half_b * half_b - a * c; + // NaN and infinity must both miss: a negative discriminant is the miss + // case, and a non-finite one has no real root to take a square root of. + let discriminant_usable: bool = discriminant.is_finite() && discriminant >= 0.0; + if !discriminant_usable { + return None; + } + let root: f64 = discriminant.sqrt(); + let near: f64 = (-half_b - root) / a; + let far: f64 = (-half_b + root) / a; + let t: f64 = if near >= t_min && near <= t_max { + near + } else if far >= t_min && far <= t_max { + far + } else { + return None; + }; + let normal: Vector3D = (origin + dir.scaled(t) - center).normalized(); + Some((t, normal)) +} + +/// Intersects a ray with an AABB, honouring the ray's parameter range. +/// +/// The slab test branches explicitly on a parallel axis instead of +/// dividing by the direction component, so a ray aligned with an axis is +/// decided by the remaining two axes rather than by `0 * infinity` +/// producing a `NaN` that comparison operators would silently accept. +/// When the origin is inside the box the exit parameter is reported, matching +/// the sphere behaviour so a shadow ray leaving a solid surface finds its way +/// out instead of being rejected as a self-hit. The reported normal is the +/// box's outward face normal, on entry and on exit alike. +/// +/// # Arguments +/// +/// - `Vector3D` - The ray origin. +/// - `Vector3D` - The ray direction, not required to be unit length. +/// - `Vector3D` - The AABB minimum corner. +/// - `Vector3D` - The AABB maximum corner. +/// - `f64` - The minimum acceptable hit parameter. +/// - `f64` - The maximum acceptable hit parameter. +/// +/// # Returns +/// +/// - `Option<(f64, Vector3D)>` - The hit parameter and the outward unit +/// normal, or `None` when the range-constrained ray misses. +fn intersect_aabb_in_range( + origin: Vector3D, + dir: Vector3D, + lower: Vector3D, + upper: Vector3D, + t_min: f64, + t_max: f64, +) -> Option<(f64, Vector3D)> { + if !is_finite_vector(origin) || !is_finite_vector(dir) { + return None; + } + if !is_finite_vector(lower) || !is_finite_vector(upper) { + return None; + } + let lo: Vector3D = vector3d_min(lower, upper); + let hi: Vector3D = vector3d_max(lower, upper); + // A box collapsed on any axis has no face a ray can strike: every point + // of its slab interval is a single plane, so reporting it would invent a + // surface where the caller supplied none. + if lo.get_x() >= hi.get_x() || lo.get_y() >= hi.get_y() || lo.get_z() >= hi.get_z() { + return None; + } + let origins: [f64; 3] = [origin.get_x(), origin.get_y(), origin.get_z()]; + let dirs: [f64; 3] = [dir.get_x(), dir.get_y(), dir.get_z()]; + let los: [f64; 3] = [lo.get_x(), lo.get_y(), lo.get_z()]; + let his: [f64; 3] = [hi.get_x(), hi.get_y(), hi.get_z()]; + let mut t_enter: f64 = f64::NEG_INFINITY; + let mut t_exit: f64 = f64::INFINITY; + let mut enter_axis: usize = 0; + let mut exit_axis: usize = 0; + for axis in 0..3 { + let d: f64 = dirs[axis]; + let o: f64 = origins[axis]; + if d.abs() < RAYTRACE_TRIANGLE_EPSILON { + if o < los[axis] || o > his[axis] { + return None; + } + continue; + } + let inverse: f64 = 1.0 / d; + let plane_a: f64 = (los[axis] - o) * inverse; + let plane_b: f64 = (his[axis] - o) * inverse; + // Dividing by a negative direction component swaps the two planes, + // so the pair must be ordered before it is compared against the + // running interval. Without this swap a ray travelling along a + // negative axis records the far plane as the near one, which both + // rejects genuine hits and reports the wrong face normal. + let (near, far): (f64, f64) = if plane_a <= plane_b { + (plane_a, plane_b) + } else { + (plane_b, plane_a) + }; + if near > t_enter { + t_enter = near; + enter_axis = axis; + } + if far < t_exit { + t_exit = far; + exit_axis = axis; + } + } + if t_enter > t_exit { + return None; + } + // The origin is inside only when the whole slab interval straddles the + // origin. Comparing `t_enter` to `t_min` alone is wrong for a ray that + // begins outside the box but before it: there `t_enter` is positive yet + // the box is still ahead of the origin, and reporting the exit would + // skip the real surface. A box entirely behind the origin has + // `t_exit < 0`, so it also fails this test and its entry parameter is + // rejected by the range check below. + let inside: bool = t_enter <= 0.0 && t_exit >= 0.0; + let t: f64 = if inside { t_exit } else { t_enter }; + if !t.is_finite() { + return None; + } + if t < t_min { + return None; + } + if t > t_max { + return None; + } + let axis: usize = if inside { exit_axis } else { enter_axis }; + // The normal is the box's own outward face normal, matching the + // documented `Hit` contract: a ray entering through the low face meets + // that face's outward direction, and a ray that started inside and + // leaves through the high face carries that face's outward direction. + // So the sign follows the face the ray meets, not the ray itself: entry + // opposes the direction of travel, exit agrees with it. + let travelling_positive: bool = dirs[axis] > 0.0; + let sign: f64 = if inside { + if travelling_positive { 1.0 } else { -1.0 } + } else if travelling_positive { + -1.0 + } else { + 1.0 + }; + let normal: Vector3D = match axis { + 0 => Vector3D::new(sign, 0.0, 0.0), + 1 => Vector3D::new(0.0, sign, 0.0), + _ => Vector3D::new(0.0, 0.0, sign), + }; + Some((t, normal)) +} + +/// Runs the exact intersection test for one occluder inside a parameter +/// range. +/// +/// This is the single source of truth for what counts as a hit: sphere, +/// AABB and triangle each dispatch to their own range-aware routine, and a +/// degenerate or non-finite occluder falls through to `None` rather than +/// registering a spurious surface. +/// +/// # Arguments +/// +/// - `&Occluder` - The occluder to test. +/// - `Vector3D` - The ray origin. +/// - `Vector3D` - The ray direction, not required to be unit length. +/// - `f64` - The minimum acceptable hit parameter. +/// - `f64` - The maximum acceptable hit parameter. +/// +/// # Returns +/// +/// - `Option<(f64, Vector3D)>` - The hit parameter and the outward unit +/// normal, or `None` when the occluder is missed or degenerate. +fn intersect_occluder_in_range( + occluder: &Occluder, + origin: Vector3D, + dir: Vector3D, + t_min: f64, + t_max: f64, +) -> Option<(f64, Vector3D)> { + match occluder.get_kind() { + OccluderKind::Sphere => intersect_sphere_in_range( + origin, + dir, + occluder.get_center(), + occluder.get_extent().get_x(), + t_min, + t_max, + ), + OccluderKind::Aabb => { + let (lower, upper): (Vector3D, Vector3D) = occluder_aabb_extents(occluder); + intersect_aabb_in_range(origin, dir, lower, upper, t_min, t_max) + } + OccluderKind::Triangle => { + let [v0, v1, v2]: [Vector3D; 3] = occluder.get_vertices(); + match intersect_triangle(origin, dir, v0, v1, v2) { + Some((t, normal)) if t.is_finite() && t >= t_min && t <= t_max => Some((t, normal)), + _ => None, + } + } + } +} + +/// Returns the tight AABB covering the primitives listed in +/// `indices[start..start + count]`. +/// +/// # Arguments +/// +/// - `&[usize]` - The primitive indexes of the range to measure. +/// - `&BvhBuild` - The per-primitive bounds and centroids. +/// - `usize` - The first index of the range. +/// - `usize` - The number of indexes in the range. +/// +/// # Returns +/// +/// - `(Vector3D, Vector3D)` - The `(min, max)` corners covering the range. +fn bounds_of_range( + indices: &[usize], + build: &BvhBuild, + start: usize, + count: usize, +) -> (Vector3D, Vector3D) { + let mut lower: Vector3D = Vector3D::new(f64::INFINITY, f64::INFINITY, f64::INFINITY); + let mut upper: Vector3D = + Vector3D::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY); + for slot in 0..count { + let primitive: usize = indices[start + slot]; + lower = vector3d_min(lower, build.lower[primitive]); + upper = vector3d_max(upper, build.upper[primitive]); + } + (lower, upper) +} + +/// Returns the widest centroid axis of a range together with its midpoint. +/// +/// This is the fallback split used when the binned heuristic finds no +/// worthwhile bin boundary. Selecting the axis by extent rather than by +/// sorting keeps a degenerate centroid cloud halvable. +/// +/// # Arguments +/// +/// - `&[usize]` - The primitive indexes of the range. +/// - `&BvhBuild` - The per-primitive bounds and centroids. +/// - `usize` - The first index of the range. +/// - `usize` - The number of indexes in the range. +/// +/// # Returns +/// +/// - `(usize, f64)` - The `(axis, midpoint)` cutting the widest centroid +/// spread. +fn longest_centroid_axis( + indices: &[usize], + build: &BvhBuild, + start: usize, + count: usize, +) -> (usize, f64) { + let centroids: &[Vector3D] = &build.centroids; + let mut best_axis: usize = 0; + let mut best_span: f64 = -1.0; + let mut best_mid: f64 = 0.0; + for axis in 0..3 { + let mut min: f64 = f64::INFINITY; + let mut max: f64 = f64::NEG_INFINITY; + for slot in 0..count { + let value: f64 = axis_component(centroids[indices[start + slot]], axis); + min = min.min(value); + max = max.max(value); + } + let span: f64 = max - min; + if span > best_span { + best_span = span; + best_axis = axis; + best_mid = (min + max) * 0.5; + } + } + (best_axis, best_mid) +} + +/// Partitions `indices[start..start + count]` around `split_value` on one +/// centroid axis and reports where the left partition ends. +/// +/// The partition is an in-place two-way scan, so one level of the build +/// costs linear time instead of sorting every axis at every level. +/// Degenerate centroid sets can place every element on one side; the caller +/// detects a `0` or `count` result and falls back to halving the range, +/// which always makes progress. +/// +/// # Arguments +/// +/// - `&mut [usize]` - The primitive index array, partitioned in place. +/// - `&BvhBuild` - The per-primitive bounds and centroids. +/// - `usize` - The first index of the range to partition. +/// - `usize` - The number of indexes in the range. +/// - `usize` - `0` for `x`, `1` for `y`, `2` for `z`. +/// - `f64` - The split position on that axis. +/// +/// # Returns +/// +/// - `usize` - The number of entries placed on the left side. +fn partition_range( + indices: &mut [usize], + build: &BvhBuild, + start: usize, + count: usize, + axis: usize, + split_value: f64, +) -> usize { + let centroids: &[Vector3D] = &build.centroids; + let mut left: usize = 0; + let mut right: usize = count; + while left < right { + while left < right && axis_component(centroids[indices[start + left]], axis) < split_value { + left += 1; + } + while right > left + && axis_component(centroids[indices[start + right - 1]], axis) >= split_value + { + right -= 1; + } + if left >= right { + break; + } + indices.swap(start + left, start + right - 1); + left += 1; + right -= 1; + } + left +} + +/// Searches all three axes for the binned surface-area-heuristic split. +/// +/// Each axis is cut into [`RAYTRACE_BVH_BINS`] bins by centroid, swept left +/// to right with a running cost, then swept right to left, so a level +/// costs linear time in the primitive count with no sorting at all. A +/// split is rejected when it cannot beat leaving the range as one leaf, +/// which keeps clustered or coincident geometry from being chopped into +/// ever thinner slices. +/// +/// # Arguments +/// +/// - `&[usize]` - The primitive indexes of the range to split. +/// - `&BvhBuild` - The per-primitive bounds and centroids. +/// - `usize` - The first index of the range. +/// - `usize` - The number of indexes in the range. +/// - `f64` - The surface area of the range's parent bounds. +/// +/// # Returns +/// +/// - `Option<(usize, f64)>` - The chosen `(axis, split position)`, or +/// `None` when no bin boundary improves on a leaf. +fn choose_sah_split( + indices: &[usize], + build: &BvhBuild, + start: usize, + count: usize, + parent_area: f64, +) -> Option<(usize, f64)> { + if count < 2 || !parent_area.is_finite() || parent_area <= 0.0 { + return None; + } + let centroids: &[Vector3D] = &build.centroids; + let mut best: Option<(usize, f64, f64)> = None; + for axis in 0..3 { + let mut centroid_min: f64 = f64::INFINITY; + let mut centroid_max: f64 = f64::NEG_INFINITY; + for slot in 0..count { + let value: f64 = axis_component(centroids[indices[start + slot]], axis); + centroid_min = centroid_min.min(value); + centroid_max = centroid_max.max(value); + } + let span: f64 = centroid_max - centroid_min; + if !span.is_finite() || span <= RAYTRACE_BVH_AXIS_EPSILON { + continue; + } + let scale: f64 = RAYTRACE_BVH_BINS as f64 / span; + let mut bin_lower: [Vector3D; RAYTRACE_BVH_BINS] = [Vector3D::zero(); RAYTRACE_BVH_BINS]; + let mut bin_upper: [Vector3D; RAYTRACE_BVH_BINS] = [Vector3D::zero(); RAYTRACE_BVH_BINS]; + let mut bin_count: [usize; RAYTRACE_BVH_BINS] = [0; RAYTRACE_BVH_BINS]; + for slot in 0..count { + let primitive: usize = indices[start + slot]; + let scaled: f64 = (axis_component(centroids[primitive], axis) - centroid_min) * scale; + let mut bin: usize = scaled as usize; + if bin >= RAYTRACE_BVH_BINS { + bin = RAYTRACE_BVH_BINS - 1; + } + if bin_count[bin] == 0 { + bin_lower[bin] = build.lower[primitive]; + bin_upper[bin] = build.upper[primitive]; + } else { + bin_lower[bin] = vector3d_min(bin_lower[bin], build.lower[primitive]); + bin_upper[bin] = vector3d_max(bin_upper[bin], build.upper[primitive]); + } + bin_count[bin] += 1; + } + let mut left_area: [f64; RAYTRACE_BVH_BINS] = [0.0; RAYTRACE_BVH_BINS]; + let mut left_count: [usize; RAYTRACE_BVH_BINS] = [0; RAYTRACE_BVH_BINS]; + let mut running_lower: Vector3D = + Vector3D::new(f64::INFINITY, f64::INFINITY, f64::INFINITY); + let mut running_upper: Vector3D = + Vector3D::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY); + let mut running_count: usize = 0; + for bin in 0..RAYTRACE_BVH_BINS { + if bin_count[bin] > 0 { + running_lower = vector3d_min(running_lower, bin_lower[bin]); + running_upper = vector3d_max(running_upper, bin_upper[bin]); + running_count += bin_count[bin]; + } + left_area[bin] = aabb_surface_area(running_lower, running_upper); + left_count[bin] = running_count; + } + let mut right_lower: Vector3D = Vector3D::new(f64::INFINITY, f64::INFINITY, f64::INFINITY); + let mut right_upper: Vector3D = + Vector3D::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY); + let mut right_count: usize = 0; + for bin in (1..RAYTRACE_BVH_BINS).rev() { + if bin_count[bin] > 0 { + right_lower = vector3d_min(right_lower, bin_lower[bin]); + right_upper = vector3d_max(right_upper, bin_upper[bin]); + right_count += bin_count[bin]; + } + let left_total: usize = left_count[bin - 1]; + if left_total == 0 || right_count == 0 { + continue; + } + let cost: f64 = left_area[bin - 1] * left_total as f64 + + aabb_surface_area(right_lower, right_upper) * right_count as f64; + if matches!(best, Some((_, _, incumbent)) if cost >= incumbent) { + continue; + } + best = Some((axis, centroid_min + bin as f64 / scale, cost)); + } + } + let (axis, boundary, cost): (usize, f64, f64) = best?; + let incumbent: f64 = count as f64 * parent_area; + if !cost.is_finite() || cost >= incumbent { + return None; + } + Some((axis, boundary)) +} + +/// Builds one BVH subtree in preorder, appending its root and descendants. +/// +/// The root is pushed before its children so the array stays a dense +/// preorder sequence: the left child lands immediately after the root, and +/// the escape is back-patched once both subtrees have been emitted. A +/// range that already fits in a leaf, or that has reached +/// [`RAYTRACE_BVH_MAX_DEPTH`], is never split: the depth cap is what keeps +/// the finished hierarchy inside the fixed traversal stack, because the +/// heuristic can otherwise chain linearly on a geometric spread. +/// +/// # Arguments +/// +/// - `&mut Vec` - The node array under construction. +/// - `&mut [usize]` - The primitive index array, permuted in place. +/// - `&BvhBuild` - The per-primitive bounds and centroids. +/// - `(Vector3D, Vector3D)` - The bounds of this subtree. +/// - `usize` - The first primitive index of this range. +/// - `usize` - The number of primitive indexes in this range. +/// - `usize` - The nesting level of this subtree, starting at zero. +fn build_bvh_subtree( + nodes: &mut Vec, + indices: &mut [usize], + build: &BvhBuild, + bounds: (Vector3D, Vector3D), + start: usize, + count: usize, + depth: usize, +) { + let root: usize = nodes.len(); + let too_small_to_split: bool = count <= RAYTRACE_BVH_LEAF_SIZE || count < 2; + if too_small_to_split || depth >= RAYTRACE_BVH_MAX_DEPTH { + nodes.push(RayTraceNode { + lower: bounds.0, + upper: bounds.1, + start, + count, + escape: root + 1, + }); + return; + } + let (axis, boundary): (usize, f64) = match choose_sah_split( + indices, + build, + start, + count, + aabb_surface_area(bounds.0, bounds.1), + ) { + Some(chosen) => chosen, + None => longest_centroid_axis(indices, build, start, count), + }; + let partitioned: usize = partition_range(indices, build, start, count, axis, boundary); + let left_count: usize = if partitioned == 0 || partitioned == count { + count / 2 + } else { + partitioned + }; + nodes.push(RayTraceNode { + lower: bounds.0, + upper: bounds.1, + start, + count: 0, + escape: root + 1, + }); + let left_bounds: (Vector3D, Vector3D) = bounds_of_range(indices, build, start, left_count); + build_bvh_subtree( + nodes, + indices, + build, + left_bounds, + start, + left_count, + depth + 1, + ); + let right_bounds: (Vector3D, Vector3D) = + bounds_of_range(indices, build, start + left_count, count - left_count); + build_bvh_subtree( + nodes, + indices, + build, + right_bounds, + start + left_count, + count - left_count, + depth + 1, + ); + nodes[root].escape = nodes.len(); +} + +/// Builds the flat BVH for a list of occluders. +/// +/// The result is the pair of arrays [`RayTraceScene`] stores: preorder +/// nodes and the permuted primitive index list the leaves address. The +/// hierarchy is built exactly once at construction, so no query pays for +/// it. +/// +/// # Arguments +/// +/// - `&[Occluder]` - The occluders to bound. +/// +/// # Returns +/// +/// - `(Vec, Vec)` - The preorder node array and the +/// reordered primitive indexes; both empty for an empty scene. +pub(crate) fn build_bvh(occluders: &[Occluder]) -> (Vec, Vec) { + let count: usize = occluders.len(); + let mut nodes: Vec = Vec::new(); + let mut indices: Vec = Vec::with_capacity(count); + if count == 0 { + return (nodes, indices); + } + let mut lower: Vec = Vec::with_capacity(count); + let mut upper: Vec = Vec::with_capacity(count); + let mut centroids: Vec = Vec::with_capacity(count); + for (index, occluder) in occluders.iter().enumerate() { + let (lo, hi): (Vector3D, Vector3D) = occluder_aabb_extents(occluder); + let midpoint: Vector3D = (lo + hi).scaled(0.5); + let centroid: Vector3D = if is_finite_vector(midpoint) { + midpoint + } else { + Vector3D::zero() + }; + lower.push(lo); + upper.push(hi); + centroids.push(centroid); + indices.push(index); + } + let build: BvhBuild = BvhBuild { + lower, + upper, + centroids, + }; + let root_bounds: (Vector3D, Vector3D) = bounds_of_range(&indices, &build, 0, count); + nodes.reserve(count.saturating_mul(2)); + build_bvh_subtree(&mut nodes, &mut indices, &build, root_bounds, 0, count, 0); + (nodes, indices) +} + +/// Reports whether a ray's range and geometry are usable for a query. +/// +/// An inverted or non-finite range, or a non-finite origin or direction, +/// cannot produce a meaningful intersection, so both query entry points +/// reject such rays instead of letting `NaN` comparisons decide. +/// +/// # Arguments +/// +/// - `&Ray` - The ray to validate. +/// +/// # Returns +/// +/// - `bool` - `true` when the ray may be traced. +fn ray_is_traceable(ray: &Ray) -> bool { + let t_min: f64 = ray.get_t_min(); + let t_max: f64 = ray.get_t_max(); + if !t_min.is_finite() || !t_max.is_finite() || t_min > t_max { + return false; + } + is_finite_vector(ray.get_origin()) && is_finite_vector(ray.get_direction()) +} + +/// Returns the direction a ray may be traced with. +/// +/// The direction is deliberately passed through unchanged. `t` is consumed +/// by `Ray::at(t)`, which advances along the raw direction, so any +/// rescaling here would make the reported parameter disagree with the point +/// the caller reconstructs from it. Each shape therefore solves for `t` in +/// the ray's own parameterisation: the sphere uses the full quadratic with +/// `a = d . d`, and the slab test divides by each direction component. +/// +/// # Arguments +/// +/// - `&Ray` - The ray to validate. +/// +/// # Returns +/// +/// - `Option` - The original direction, or `None` when the ray is +/// degenerate and cannot be traced without a division blow-up. +fn traceable_direction_of(ray: &Ray) -> Option { + if !ray_is_traceable(ray) { + return None; + } + let dir: Vector3D = ray.get_direction(); + let squared_length: f64 = dir.dot(dir); + if !squared_length.is_finite() || squared_length <= 0.0 { + return None; + } + Some(dir) +} + +/// Runs the closest-hit query as a straight scan over every primitive. +/// +/// Used for scenes too small for the hierarchy to pay for itself. The tie +/// rule is identical to the traversal's: a nearer hit always wins, and an +/// exact tie is resolved towards the lower original index, so the linear +/// and hierarchical paths cannot disagree on the same scene. +/// +/// # Arguments +/// +/// - `&Ray` - The ray being traced. +/// - `&[Occluder]` - Every primitive in the scene. +/// - `Vector3D` - The validated ray direction. +/// +/// # Returns +/// +/// - `Option<(usize, f64, Vector3D, Vector3D)>` - The winning primitive +/// index, the hit parameter, the hit point and the surface normal. +fn linear_closest_hit( + ray: &Ray, + occluders: &[Occluder], + dir: Vector3D, +) -> Option<(usize, f64, Vector3D, Vector3D)> { + let t_min: f64 = ray.get_t_min(); + let mut best: Option<(usize, f64, Vector3D, Vector3D)> = None; + for (index, occluder) in occluders.iter().enumerate() { + let hit: Option<(f64, Vector3D)> = + intersect_occluder_in_range(occluder, ray.get_origin(), dir, t_min, ray.get_t_max()); + let Some((t, normal)) = hit else { + continue; + }; + let nearer: bool = match &best { + None => true, + Some((best_index, best_t, _, _)) => { + t < *best_t || (t == *best_t && index < *best_index) + } + }; + if nearer { + best = Some((index, t, ray.get_origin() + dir.scaled(t), normal)); + } + } + best +} + +/// Runs the any-hit query as a straight scan over every primitive. +/// +/// # Arguments +/// +/// - `&Ray` - The ray being tested. +/// - `&[Occluder]` - Every primitive in the scene. +/// - `Vector3D` - The validated ray direction. +/// +/// # Returns +/// +/// - `bool` - Whether any primitive blocks the ray. +fn linear_occluded(ray: &Ray, occluders: &[Occluder], dir: Vector3D) -> bool { + occluders.iter().any(|occluder: &Occluder| { + intersect_occluder_in_range( + occluder, + ray.get_origin(), + dir, + ray.get_t_min(), + ray.get_t_max(), + ) + .is_some() + }) +} + +/// Finds the closest intersection between a ray and a scene's BVH without +/// touching any [`Material`]. +/// +/// Traversal walks the preorder node array with a fixed-size stack held on +/// the caller's frame, so a query allocates nothing. Each node is tested +/// against the current best distance, which prunes whole subtrees, and the +/// nearer child is pushed last so it pops first. Ties are broken by the +/// lowest original primitive index rather than by traversal order, so the +/// result is stable no matter how the tree was built. +/// +/// # Arguments +/// +/// - `&Ray` - The ray to test. +/// - `&RayTraceScene` - The scene holding the hierarchy. +/// +/// # Returns +/// +/// - `Option<(usize, f64, Vector3D, Vector3D)>` - The occluder index, the +/// hit distance `t`, the hit position, and the surface normal, or `None` +/// if the ray misses. +pub(crate) fn closest_hit_indexed( + ray: &Ray, + scene: &RayTraceScene, +) -> Option<(usize, f64, Vector3D, Vector3D)> { + let dir: Vector3D = traceable_direction_of(ray)?; + let nodes: &[RayTraceNode] = scene.get_nodes(); + if nodes.is_empty() { + return None; + } + let occluders: &[Occluder] = scene.get_occluders(); + if occluders.len() <= RAYTRACE_LINEAR_MAX_PRIMITIVES { + return linear_closest_hit(ray, occluders, dir); + } + let occluders: &[Occluder] = scene.get_occluders(); + let indices: &[usize] = scene.get_indices(); + let origin: Vector3D = ray.get_origin(); + let t_min: f64 = ray.get_t_min(); + let mut best_t: f64 = ray.get_t_max(); + let mut best_index: usize = 0; + let mut best_normal: Vector3D = Vector3D::zero(); + let mut found: bool = false; + let mut stack: [usize; RAYTRACE_BVH_STACK_SIZE] = [0usize; RAYTRACE_BVH_STACK_SIZE]; + let root: &RayTraceNode = &nodes[0]; + // The root's entry parameter is only a probe: a root the ray misses means + // the whole subtree is out of range, so `?` on the discarded value is the + // early return clippy asks for. + let _root_entry: f64 = aabb_entry_parameter( + origin, + dir, + root.get_lower(), + root.get_upper(), + t_min, + best_t, + )?; + stack[0] = 0usize; + let mut depth: usize = 1; + while depth > 0 { + depth -= 1; + let node_index: usize = stack[depth]; + let node: RayTraceNode = nodes[node_index]; + if node.get_count() == 0 { + let left_index: usize = node_index + 1; + let right_index: usize = nodes[left_index].get_escape(); + let left: &RayTraceNode = &nodes[left_index]; + let right: &RayTraceNode = &nodes[right_index]; + let left_entry: Option = aabb_entry_parameter( + origin, + dir, + left.get_lower(), + left.get_upper(), + t_min, + best_t, + ); + let right_entry: Option = aabb_entry_parameter( + origin, + dir, + right.get_lower(), + right.get_upper(), + t_min, + best_t, + ); + match (left_entry, right_entry) { + (None, None) => {} + (Some(_), None) => { + stack[depth] = left_index; + depth += 1; + } + (None, Some(_)) => { + stack[depth] = right_index; + depth += 1; + } + (Some(left_t), Some(right_t)) => { + let near_first: usize = if left_t <= right_t { + left_index + } else { + right_index + }; + stack[depth] = if left_t <= right_t { + right_index + } else { + left_index + }; + depth += 1; + stack[depth] = near_first; + depth += 1; + } + } + continue; + } + let start: usize = node.get_start(); + for slot in 0..node.get_count() { + let primitive: usize = indices[start + slot]; + let hit: Option<(f64, Vector3D)> = + intersect_occluder_in_range(&occluders[primitive], origin, dir, t_min, best_t); + if let Some((t, normal)) = hit { + let nearer: bool = t < best_t; + let stable_tie: bool = t == best_t && (!found || primitive < best_index); + if nearer || stable_tie { + best_t = t; + best_index = primitive; + best_normal = normal; + found = true; + } + } + } + } + if !found { + return None; + } + Some((best_index, best_t, origin + dir.scaled(best_t), best_normal)) +} + +/// Reports whether any occluder blocks the ray's parameter range. +/// +/// This is the any-hit counterpart to [`closest_hit_indexed`]: it returns +/// as soon as one occluder is hit, so a shadow ray stops at the first +/// blocker instead of resolving the whole hierarchy. +/// +/// # Arguments +/// +/// - `&Ray` - The ray to test. +/// - `&RayTraceScene` - The scene holding the hierarchy. +/// +/// # Returns +/// +/// - `bool` - `true` when at least one occluder intersects the range. +pub(crate) fn occluded_indexed(ray: &Ray, scene: &RayTraceScene) -> bool { + let untraceable: Option = traceable_direction_of(ray); + let dir: Vector3D = match untraceable { + Some(direction) => direction, + None => return false, + }; + let occluders: &[Occluder] = scene.get_occluders(); + if occluders.len() <= RAYTRACE_LINEAR_MAX_PRIMITIVES { + return linear_occluded(ray, occluders, dir); + } + let nodes: &[RayTraceNode] = scene.get_nodes(); + if nodes.is_empty() { + return false; + } + let occluders: &[Occluder] = scene.get_occluders(); + let indices: &[usize] = scene.get_indices(); + let origin: Vector3D = ray.get_origin(); + let t_min: f64 = ray.get_t_min(); + let t_max: f64 = ray.get_t_max(); + let mut stack: [usize; RAYTRACE_BVH_STACK_SIZE] = [0usize; RAYTRACE_BVH_STACK_SIZE]; + let root: &RayTraceNode = &nodes[0]; + if aabb_entry_parameter( + origin, + dir, + root.get_lower(), + root.get_upper(), + t_min, + t_max, + ) + .is_none() + { + return false; + } + stack[0] = 0usize; + let mut depth: usize = 1; + while depth > 0 { + depth -= 1; + let node_index: usize = stack[depth]; + let node: RayTraceNode = nodes[node_index]; + if node.get_count() == 0 { + let left_index: usize = node_index + 1; + let right_index: usize = nodes[left_index].get_escape(); + for child_index in [left_index, right_index] { + let child: &RayTraceNode = &nodes[child_index]; + let entry: Option = aabb_entry_parameter( + origin, + dir, + child.get_lower(), + child.get_upper(), + t_min, + t_max, + ); + if entry.is_some() { + stack[depth] = child_index; + depth += 1; + } + } + continue; + } + let start: usize = node.get_start(); + for slot in 0..node.get_count() { + let primitive: usize = indices[start + slot]; + let blocked: bool = + intersect_occluder_in_range(&occluders[primitive], origin, dir, t_min, t_max) + .is_some(); + if blocked { + return true; + } + } + } + false +} + +/// Builds a reflected ray bouncing off a surface point with the given +/// normal. +/// +/// # Arguments +/// +/// - `&Ray` - The incoming ray. +/// - `Vector3D` - The world-space hit position. +/// - `Vector3D` - The outward unit normal at the hit point. +/// +/// # Returns +/// +/// - `Ray` - A new ray originating at the hit point with the reflected +/// direction, `t_min` reset to `RAYTRACE_DEFAULT_T_MIN`, `t_max` set to +/// `RAYTRACE_DEFAULT_T_MAX`, and `depth` incremented by one. +fn bounce_ray(ray: &Ray, position: Vector3D, normal: Vector3D) -> Ray { + let dir: Vector3D = ray.get_direction(); + let dot: f64 = dir.dot(normal); + let reflected_dir: Vector3D = dir - normal.scaled(2.0 * dot); + Ray { + origin: position, + direction: reflected_dir, + t_min: RAYTRACE_DEFAULT_T_MIN, + t_max: RAYTRACE_DEFAULT_T_MAX, + depth: ray.get_depth() + 1, + } +} + +/// Builds the shadow visibility callback consumed by +/// `LightingUniforms::shade_with_visibility`. +/// +/// The callback turns a light into one exact any-hit query against the +/// scene's BVH. The ray leaves the shading point along the surface normal +/// rather than the surface itself, so a lit surface never shadows itself +/// through its own footprint, and the segment stops short of a finite light +/// position so geometry enclosing the emitter does not stay dark. A +/// surface already facing away from the light is reported as lit without +/// spending a query at all. +/// +/// The returned factor is the full `1.0` / `0.0` visibility the shader +/// path also uses, so CPU and GPU agree on what "shadowed" means. +/// +/// # Arguments +/// +/// - `&'a RayTraceScene` - The scene to query for blockers. +/// - `Vector3D` - The world-space shading position. +/// - `Vector3D` - The surface normal at that position. +/// +/// # Returns +/// - `impl FnMut(&Light, Vector3D, f64) -> f64` - A callback returning the +/// light's visibility from the shading point. +pub(crate) fn shadow_visibility<'a>( + scene: &'a RayTraceScene, + position: Vector3D, + normal: Vector3D, +) -> impl FnMut(&Light, Vector3D, f64) -> f64 + 'a { + let origin: Vector3D = position + normal.scaled(RAYTRACE_SHADOW_SELF_OFFSET); + move |light: &Light, light_direction: Vector3D, max_distance: f64| { + let facing: f64 = normal.dot(light_direction); + if facing <= 0.0 { + return 1.0; + } + let length: f64 = light_direction.magnitude(); + let distance_usable: bool = + length.is_finite() && length > 0.0 && max_distance.is_finite() && max_distance > 0.0; + if !distance_usable { + return 1.0; + } + let limit: f64 = match light.get_kind() { + LightType::Directional => max_distance, + LightType::Point | LightType::Spot => max_distance - RAYTRACE_SHADOW_LIGHT_EPSILON, + }; + let limit: f64 = if limit > RAYTRACE_SHADOW_SELF_OFFSET { + limit + } else { + return 1.0; + }; + let dir: Vector3D = light_direction.scaled(1.0 / length); + let ray: Ray = Ray::new(origin, dir).with_range(RAYTRACE_SHADOW_SELF_OFFSET, limit); + let blocked: bool = occluded_indexed(&ray, scene); + if blocked { 0.0 } else { 1.0 } + } +} + +/// Iteratively traces a ray against a scene using its BVH, performing no +/// heap allocation per bounce. +/// +/// Shadows are resolved exactly through the scene's own any-hit traversal +/// rather than through the legacy bounding-sphere list, so a traced hit is +/// shaded with the same visibility the GPU path computes. Color +/// contributions are accumulated with a specular throughput: each bounce +/// multiplies the throughput by the hit material's specular intensity, and +/// a miss adds the ambient color scaled by the current throughput. The +/// bounce loop stops when the ray misses, when `depth` reaches +/// `max_bounces`, or when the hit material's specular intensity is not +/// greater than [`EPSILON`], matching the behavior of the historical +/// recursive formulation. +/// +/// # Arguments +/// +/// - `Ray` - The ray to trace. +/// - `&RayTraceScene` - The scene to trace through; its BVH resolves every +/// closest-hit and every shadow query. +/// - `&LightingUniforms` - Lighting parameters used during shading. +/// - `u32` - The maximum number of bounces allowed for this ray. +/// +/// # Returns +/// +/// - `Vector3D` - The final traced color. +pub(crate) fn trace_bounces( + ray: Ray, + scene: &RayTraceScene, + lights: &LightingUniforms, + max_bounces: u32, +) -> Vector3D { + let ambient: Vector3D = lights.get_ambient(); + let mut color: Vector3D = Vector3D::zero(); + let mut throughput: f64 = 1.0; + let mut current: Ray = ray; + loop { + let (index, _t, position, normal): (usize, f64, Vector3D, Vector3D) = + match closest_hit_indexed(¤t, scene) { + None => { + color += ambient.scaled(throughput); + break; + } + Some(hit) => hit, + }; + let material: &Material = scene.get_occluders()[index].get_material(); + let shaded: Vector3D = lights.shade_with_visibility( + position, + normal, + material, + shadow_visibility(scene, position, normal), + ); + color += shaded.scaled(throughput); + let spec: f64 = material.get_specular(); + if current.get_depth() >= max_bounces || spec <= EPSILON { + break; + } + throughput *= spec; + current = bounce_ray(¤t, position, normal); + } + color +} diff --git a/engine/src/raytracing/impl.rs b/engine/src/raytracing/scene/impl.rs similarity index 78% rename from engine/src/raytracing/impl.rs rename to engine/src/raytracing/scene/impl.rs index 735dd3b7..b8ce41ad 100644 --- a/engine/src/raytracing/impl.rs +++ b/engine/src/raytracing/scene/impl.rs @@ -58,6 +58,31 @@ impl Ray { } } + /// Returns a clone of this ray with its parameter range replaced. + /// + /// The new bounds are taken as given, so an inverted or non-finite + /// range is representable; both query entry points reject such a ray + /// as untraceable rather than letting `NaN` comparisons decide the + /// outcome. + /// + /// # Arguments + /// + /// - `f64` - The minimum accepted hit parameter. + /// - `f64` - The maximum accepted hit parameter. + /// + /// # Returns + /// + /// - `Ray` - The cloned ray with the new range. + pub fn with_range(&self, t_min: f64, t_max: f64) -> Ray { + Ray { + origin: self.get_origin(), + direction: self.get_direction(), + t_min, + t_max, + depth: self.get_depth(), + } + } + /// Intersects this ray with the triangle `v0`, `v1`, `v2` using the /// Moller-Trumbore algorithm, keeping only hits inside this ray's /// `t_min`..=`t_max` range. @@ -180,8 +205,13 @@ impl Occluder { /// [`RayTraceScene`]. impl RayTraceScene { /// Creates a new scene taking ownership of `occluders` and precomputing - /// the `(center, radius)` shadow bounding spheres used by - /// [`soft_shadow_factor`]. + /// both the bounding volume hierarchy and the `(center, radius)` shadow + /// bounding spheres used by [`soft_shadow_factor`]. + /// + /// The hierarchy is built once here; every later query reuses it, so + /// the cost of the BVH never appears in a per-ray measurement. The + /// public surface is unchanged from the pre-BVH constructor, which + /// takes the occluder list by value. /// /// # Arguments /// @@ -192,8 +222,11 @@ impl RayTraceScene { /// - `RayTraceScene` - The new scene with precomputed shadow data. pub fn new(occluders: Vec) -> RayTraceScene { let shadow_points: Vec<(Vector3D, f64)> = collect_occluder_points(&occluders); + let (nodes, indices): (Vec, Vec) = build_bvh(&occluders); RayTraceScene { occluders, + nodes, + indices, shadow_points, } } @@ -244,13 +277,7 @@ impl RayTraceScene { lights: &LightingUniforms, max_bounces: u32, ) -> Vector3D { - trace_bounces( - ray, - self.get_occluders(), - self.get_shadow_points(), - lights, - max_bounces, - ) + trace_bounces(ray, self, lights, max_bounces) } /// Finds the closest intersection between a ray and the scene @@ -258,6 +285,10 @@ impl RayTraceScene { /// /// The winning occluder's [`Material`] is cloned exactly once, when the /// returned [`Hit`] is constructed; losing candidates are never cloned. + /// Traversal itself resolves every candidate through the scene's + /// bounding volume hierarchy, so the cost scales with the parts of the + /// hierarchy the ray actually crosses rather than with the occluder + /// count. /// /// # Arguments /// @@ -268,7 +299,7 @@ impl RayTraceScene { /// - `Option` - The closest hit, or `None` if the ray misses. pub fn closest_hit(&self, ray: &Ray) -> Option { let occluders: &[Occluder] = self.get_occluders(); - closest_hit_indexed(ray, occluders).map( + closest_hit_indexed(ray, self).map( |(index, t, position, normal): (usize, f64, Vector3D, Vector3D)| Hit { t, position, @@ -277,4 +308,25 @@ impl RayTraceScene { }, ) } + + /// Reports whether anything in the scene blocks the ray's parameter + /// range. + /// + /// This is the any-hit counterpart to [`RayTraceScene::closest_hit`], + /// built for shadow segments: it stops at the first blocker instead of + /// resolving the whole hierarchy, so a fully occluded light costs less + /// than a fully visible one. An empty scene reports `false`, and a ray + /// with an inverted or non-finite range reports `false` rather than + /// inventing a hit. + /// + /// # Arguments + /// + /// - `&Ray` - The ray to test. + /// + /// # Returns + /// + /// - `bool` - `true` when at least one occluder intersects the range. + pub fn occluded(&self, ray: &Ray) -> bool { + occluded_indexed(ray, self) + } } diff --git a/engine/src/raytracing/scene/mod.rs b/engine/src/raytracing/scene/mod.rs new file mode 100644 index 00000000..cf8b81f1 --- /dev/null +++ b/engine/src/raytracing/scene/mod.rs @@ -0,0 +1,15 @@ +mod r#const; +mod r#enum; +mod r#fn; +mod r#impl; +mod r#struct; + +pub use {r#enum::*, r#fn::intersect_triangle, r#struct::*}; + +pub(crate) use r#const::*; +pub(crate) use r#fn::{ + build_bvh, closest_hit_indexed, collect_occluder_points, occluded_indexed, trace_bounces, +}; +pub(crate) use r#struct::{BvhBuild, RayTraceNode}; + +use super::*; diff --git a/engine/src/raytracing/scene/struct.rs b/engine/src/raytracing/scene/struct.rs new file mode 100644 index 00000000..804d06bb --- /dev/null +++ b/engine/src/raytracing/scene/struct.rs @@ -0,0 +1,176 @@ +use super::*; + +/// A 3D ray carrying the parameters needed for traversal and depth tracking. +#[derive(Clone, Data, Debug, PartialEq)] +pub struct Ray { + /// The world-space origin of the ray. + #[get(type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] + pub origin: Vector3D, + /// The unit direction of the ray. + #[get(type(copy))] + #[get_mut(pub(crate))] + pub direction: Vector3D, + /// The minimum acceptable `t` value for valid intersections. + #[get(type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] + pub t_min: f64, + /// The maximum `t` value before the ray escapes the scene. + #[get(type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] + pub t_max: f64, + /// The current recursion depth (used to terminate recursive bounces). + #[get(type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] + pub depth: u32, +} + +/// The result of a ray-object intersection. +#[derive(Clone, Data, Debug, PartialEq)] +pub struct Hit { + /// The ray parameter at the hit point. + #[get(type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] + pub t: f64, + /// The world-space hit position. + #[get(type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] + pub position: Vector3D, + /// The outward unit normal at the hit point. + #[get(type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] + pub normal: Vector3D, + /// The material of the hit surface. + #[get(pub(crate), type(clone))] + #[get_mut(pub(crate))] + #[set(pub(crate))] + pub(crate) material: Material, +} + +/// A ray-traceable geometric surface with an attached material. +#[derive(Clone, Data, Debug, PartialEq)] +pub struct Occluder { + /// The geometric shape represented by this occluder. + #[get(type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] + pub kind: OccluderKind, + /// For spheres: the center. For AABBs: the minimum corner. Unused by + /// triangles, whose vertices live in `vertices`. + #[get(type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] + pub center: Vector3D, + /// For spheres: `.x` is the radius. For AABBs: the maximum corner. + /// Unused by triangles, whose vertices live in `vertices`. + #[get(type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] + pub extent: Vector3D, + /// For triangles: the three corners in winding order. Unused by spheres + /// and AABBs, which are fully described by `center` and `extent`. + #[get(type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] + pub vertices: [Vector3D; 3], + /// The surface material. + #[get(pub(crate))] + #[get_mut(pub(crate))] + #[set(pub(crate))] + pub(crate) material: Material, +} + +/// One node of the flat bounding volume hierarchy owned by +/// [`RayTraceScene`]. +/// +/// The hierarchy is stored as a preorder array: a node is followed +/// immediately by its left child at `index + 1`, and `escape` holds the +/// index of the first node after this node's entire subtree. A node with +/// `count == 0` is internal, and traversal either descends into +/// `index + 1` or jumps to `escape` when the ray misses the bounds. A node +/// with `count > 0` is a leaf whose `start`/`count` slice of the scene's +/// `indices` array lists the primitives it owns, reordered but never +/// renumbered: every entry stays the occluder's original index. +#[derive(Clone, Copy, Data, Debug, PartialEq)] +pub(crate) struct RayTraceNode { + /// The minimum corner of the axis-aligned bounds covering the subtree. + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] + pub(crate) lower: Vector3D, + /// The maximum corner of the axis-aligned bounds covering the subtree. + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] + pub(crate) upper: Vector3D, + /// The first entry of this leaf's primitive slice in the scene's + /// `indices` array; unused when `count == 0`. + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] + pub(crate) start: usize, + /// The number of primitives in this leaf; zero marks an internal node. + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] + pub(crate) count: usize, + /// The index of the first node after this subtree, so a miss can skip + /// the whole subtree in one step. + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] + pub(crate) escape: usize, +} + +/// The per-primitive bounds and centroids a BVH build reads while +/// splitting. +/// +/// Holding them apart from [`RayTraceScene`] keeps the build scratch out +/// of the stored scene: once the hierarchy is built the three arrays are +/// dropped, so nothing a query touches carries build-time state. +#[derive(Debug)] +pub(crate) struct BvhBuild { + /// The minimum corner of each primitive's world-space bounds. + pub(crate) lower: Vec, + /// The maximum corner of each primitive's world-space bounds. + pub(crate) upper: Vec, + /// The midpoint of each primitive's bounds, used to bin and partition. + pub(crate) centroids: Vec, +} + +/// An owned ray-tracing scene with a precomputed bounding volume +/// hierarchy and shadow data. +/// +/// `RayTraceScene` bundles the occluder list, the flat BVH over their +/// bounds, and the `(center, radius)` shadow bounding spheres consumed by +/// [`soft_shadow_factor`], computing both exactly once at construction. +/// Tracing through [`RayTraceScene::trace`] and querying through +/// [`RayTraceScene::closest_hit`] perform no heap allocation per ray or +/// per bounce, making the scene the single canonical entry point for +/// tracing many rays against a static scene. +#[derive(Clone, Debug, Getter, PartialEq)] +pub struct RayTraceScene { + /// All occluding surfaces in the scene. + #[get(pub(crate))] + pub(crate) occluders: Vec, + /// The flat preorder BVH over the occluders' bounds. Shared verbatim + /// with the GPU encoders, so the layout is part of the internal + /// contract rather than an implementation detail. + #[get(pub(crate))] + pub(crate) nodes: Vec, + /// The reordered primitive indexes addressed by the leaves' `start` and + /// `count` fields. Every value stays the occluder's original index. + #[get(pub(crate))] + pub(crate) indices: Vec, + /// Precomputed `(center, radius)` shadow bounding spheres, one per + /// occluder, in the same order as `occluders`. + #[get(pub(crate))] + pub(crate) shadow_points: Vec<(Vector3D, f64)>, +} diff --git a/engine/src/raytracing/struct.rs b/engine/src/raytracing/struct.rs deleted file mode 100644 index ab13eb7d..00000000 --- a/engine/src/raytracing/struct.rs +++ /dev/null @@ -1,111 +0,0 @@ -use super::*; - -/// A 3D ray carrying the parameters needed for traversal and depth tracking. -#[derive(Clone, Data, Debug, PartialEq)] -pub struct Ray { - /// The world-space origin of the ray. - #[get(type(copy))] - #[get_mut(pub(crate))] - #[set(pub(crate))] - pub(crate) origin: Vector3D, - /// The unit direction of the ray. - #[get(type(copy))] - #[get_mut(pub(crate))] - pub(crate) direction: Vector3D, - /// The minimum acceptable `t` value for valid intersections. - #[get(type(copy))] - #[get_mut(pub(crate))] - #[set(pub(crate))] - pub(crate) t_min: f64, - /// The maximum `t` value before the ray escapes the scene. - #[get(type(copy))] - #[get_mut(pub(crate))] - #[set(pub(crate))] - pub(crate) t_max: f64, - /// The current recursion depth (used to terminate recursive bounces). - #[get(type(copy))] - #[get_mut(pub(crate))] - #[set(pub(crate))] - pub(crate) depth: u32, -} - -/// The result of a ray-object intersection. -#[derive(Clone, Data, Debug, PartialEq)] -pub struct Hit { - /// The ray parameter at the hit point. - #[get(type(copy))] - #[get_mut(pub(crate))] - #[set(pub(crate))] - pub(crate) t: f64, - /// The world-space hit position. - #[get(type(copy))] - #[get_mut(pub(crate))] - #[set(pub(crate))] - pub(crate) position: Vector3D, - /// The outward unit normal at the hit point. - #[get(type(copy))] - #[get_mut(pub(crate))] - #[set(pub(crate))] - pub(crate) normal: Vector3D, - /// The material of the hit surface. - #[get(pub(crate))] - #[get_mut(pub(crate))] - #[set(pub(crate))] - pub(crate) material: Material, -} - -/// A ray-traceable geometric surface with an attached material. -#[derive(Clone, Data, Debug, PartialEq)] -pub struct Occluder { - /// The geometric shape represented by this occluder. - #[get(type(copy))] - #[get_mut(pub(crate))] - #[set(pub(crate))] - pub(crate) kind: OccluderKind, - /// For spheres: the center. For AABBs: the minimum corner. Unused by - /// triangles, whose vertices live in `vertices`. - #[get(type(copy))] - #[get_mut(pub(crate))] - #[set(pub(crate))] - pub(crate) center: Vector3D, - /// For spheres: `.x` is the radius. For AABBs: the maximum corner. - /// Unused by triangles, whose vertices live in `vertices`. - #[get(type(copy))] - #[get_mut(pub(crate))] - #[set(pub(crate))] - pub(crate) extent: Vector3D, - /// For triangles: the three corners in winding order. Unused by spheres - /// and AABBs, which are fully described by `center` and `extent`. - #[get(type(copy))] - #[get_mut(pub(crate))] - #[set(pub(crate))] - pub(crate) vertices: [Vector3D; 3], - /// The surface material. - #[get(pub(crate))] - #[get_mut(pub(crate))] - #[set(pub(crate))] - pub(crate) material: Material, -} - -/// An owned ray-tracing scene with precomputed shadow data. -/// -/// `RayTraceScene` bundles the occluder list together with the -/// `(center, radius)` shadow bounding spheres consumed by -/// [`soft_shadow_factor`], computing them exactly once at construction. -/// Tracing through [`RayTraceScene::trace`] performs no heap allocation -/// per ray or per bounce, making the scene the single canonical entry -/// point for tracing many rays against a static scene. -#[derive(Clone, Data, Debug, PartialEq)] -pub struct RayTraceScene { - /// All occluding surfaces in the scene. - #[get_mut(skip)] - #[set(skip)] - #[get(pub(crate))] - pub(crate) occluders: Vec, - /// Precomputed `(center, radius)` shadow bounding spheres, one per - /// occluder, in the same order as `occluders`. - #[get_mut(skip)] - #[set(skip)] - #[get(pub(crate))] - pub(crate) shadow_points: Vec<(Vector3D, f64)>, -} diff --git a/engine/src/renderer/canvas/impl.rs b/engine/src/renderer/canvas/impl.rs index 93094b3f..37edb604 100644 --- a/engine/src/renderer/canvas/impl.rs +++ b/engine/src/renderer/canvas/impl.rs @@ -1220,7 +1220,6 @@ impl SsaaCanvas { display_context, offscreen_canvas, offscreen_context, - scale_factor, width, height, ); diff --git a/engine/src/renderer/canvas/struct.rs b/engine/src/renderer/canvas/struct.rs index f91962b4..c2973adc 100644 --- a/engine/src/renderer/canvas/struct.rs +++ b/engine/src/renderer/canvas/struct.rs @@ -5,19 +5,26 @@ use super::*; pub struct Camera2D { /// The world-space position of the camera center. #[get(type(copy))] - pub(crate) position: Vector2D, + #[get_mut(pub(crate))] + pub position: Vector2D, /// The zoom factor (1.0 = no zoom, 2.0 = 2x magnification). #[get(type(copy))] - pub(crate) zoom: f64, + #[get_mut(pub(crate))] + pub zoom: f64, /// The rotation angle in radians. #[get(type(copy))] - pub(crate) rotation: f64, + #[get_mut(pub(crate))] + pub rotation: f64, /// The viewport width in screen pixels. #[get(type(copy))] - pub(crate) viewport_width: f64, + #[get_mut(pub(crate))] + #[set(pub(crate))] + pub viewport_width: f64, /// The viewport height in screen pixels. #[get(type(copy))] - pub(crate) viewport_height: f64, + #[get_mut(pub(crate))] + #[set(pub(crate))] + pub viewport_height: f64, } /// A 3D camera that defines the viewport into a 3D world using perspective @@ -26,31 +33,41 @@ pub struct Camera2D { pub struct Camera3D { /// The world-space position of the camera (eye). #[get(type(copy))] - pub(crate) position: Vector3D, + #[get_mut(pub(crate))] + pub position: Vector3D, /// The point the camera is looking at (target). #[get(type(copy))] - pub(crate) target: Vector3D, + #[get_mut(pub(crate))] + pub target: Vector3D, /// The up direction for the camera. - #[get(type(copy))] #[new(skip)] - pub(crate) up: Vector3D, - /// The vertical field of view in radians. #[get(type(copy))] + #[get_mut(pub(crate))] + pub up: Vector3D, + /// The vertical field of view in radians. #[new(skip)] - pub(crate) fov: f64, - /// The near clipping plane distance. #[get(type(copy))] + #[get_mut(pub(crate))] + pub fov: f64, + /// The near clipping plane distance. #[new(skip)] - pub(crate) near: f64, - /// The far clipping plane distance. #[get(type(copy))] + #[get_mut(pub(crate))] + pub near: f64, + /// The far clipping plane distance. #[new(skip)] - pub(crate) far: f64, - /// The viewport width in pixels. #[get(type(copy))] + #[get_mut(pub(crate))] + pub far: f64, + /// The viewport width in pixels. + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) viewport_width: f64, /// The viewport height in pixels. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) viewport_height: f64, } @@ -59,15 +76,22 @@ pub struct Camera3D { #[derive(Clone, Data, New)] pub struct CanvasRenderer { /// The underlying canvas 2D rendering context. + #[get(pub(crate))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) context: CanvasRenderingContext2d, /// The active camera controlling the viewport. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) camera: Camera2D, /// The active rendering quality preset. /// /// Controls `imageSmoothingEnabled`, `imageSmoothingQuality`, and /// `textRendering` on the underlying context. Defaults to `Medium`. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) quality: RenderQuality, } @@ -79,12 +103,18 @@ pub struct CanvasRenderer { pub struct LinearGradient { /// The starting point of the gradient in world space. #[get(type(copy))] - pub(crate) start: Vector2D, + #[get_mut(pub(crate))] + #[set(pub(crate))] + pub start: Vector2D, /// The ending point of the gradient in world space. #[get(type(copy))] - pub(crate) end: Vector2D, + #[get_mut(pub(crate))] + #[set(pub(crate))] + pub end: Vector2D, /// The ordered list of color stops, each containing a position (0.0 to 1.0) and a CSS color string. - pub(crate) stops: Vec<(f64, String)>, + #[get_mut(pub(crate))] + #[set(pub(crate))] + pub stops: Vec<(f64, String)>, } /// A radial gradient defined by inner and outer circles and a list of color stops. @@ -95,18 +125,28 @@ pub struct LinearGradient { pub struct RadialGradient { /// The center of the inner circle of the gradient. #[get(type(copy))] - pub(crate) inner_center: Vector2D, + #[get_mut(pub(crate))] + #[set(pub(crate))] + pub inner_center: Vector2D, /// The radius of the inner circle. #[get(type(copy))] - pub(crate) inner_radius: f64, + #[get_mut(pub(crate))] + #[set(pub(crate))] + pub inner_radius: f64, /// The center of the outer circle of the gradient. #[get(type(copy))] - pub(crate) outer_center: Vector2D, + #[get_mut(pub(crate))] + #[set(pub(crate))] + pub outer_center: Vector2D, /// The radius of the outer circle. #[get(type(copy))] - pub(crate) outer_radius: f64, + #[get_mut(pub(crate))] + #[set(pub(crate))] + pub outer_radius: f64, /// The ordered list of color stops, each containing a position (0.0 to 1.0) and a CSS color string. - pub(crate) stops: Vec<(f64, String)>, + #[get_mut(pub(crate))] + #[set(pub(crate))] + pub stops: Vec<(f64, String)>, } /// Shadow rendering configuration for drop shadow effects on canvas primitives. @@ -116,16 +156,24 @@ pub struct RadialGradient { #[derive(Clone, Data, Debug, New, PartialEq, PartialOrd)] pub struct ShadowConfig { /// The CSS color string of the shadow (e.g., `"rgba(0,0,0,0.5)"`). - #[get(type(clone))] + #[get(pub(crate), type(clone))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) color: String, /// The blur radius of the shadow in pixels. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) blur: f64, /// The horizontal offset of the shadow in pixels. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) offset_x: f64, /// The vertical offset of the shadow in pixels. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) offset_y: f64, } @@ -137,10 +185,14 @@ pub struct ShadowConfig { pub struct RenderLayer { /// The z-index determining draw order. Higher values draw later (on top). #[get(type(copy))] - pub(crate) z_index: i32, + #[get_mut(pub(crate))] + #[set(pub(crate))] + pub z_index: i32, /// Whether objects in this layer should be rendered. #[get(type(copy))] - pub(crate) visible: bool, + #[get_mut(pub(crate))] + #[set(pub(crate))] + pub visible: bool, } /// An ordered buffer of deferred draw commands recorded during a frame. @@ -155,41 +207,55 @@ pub struct DrawList { /// The recorded draw commands for the current frame. #[get_mut(pub(crate))] #[set(pub(crate))] - pub(crate) commands: Vec, + pub commands: Vec, } /// A supersampling anti-aliasing (SSAA) canvas wrapper that renders at a higher /// resolution on an offscreen canvas and downscales to the display canvas for /// smoother polygon edges in software-rendered 3D scenes. /// -/// The offscreen context is scaled by `scale_factor` so that all drawing -/// code can use logical pixel coordinates without modification. After +/// The offscreen context is scaled by the supersampling factor so that all +/// drawing code can use logical pixel coordinates without modification. After /// rendering, call `present()` to draw the high-resolution buffer onto the /// visible canvas with high-quality image smoothing. #[derive(Clone, Data, New)] pub struct SsaaCanvas { /// The display canvas element visible to the user. - pub(crate) display_canvas: HtmlCanvasElement, + #[get_mut(pub(crate))] + #[set(pub(crate))] + pub display_canvas: HtmlCanvasElement, /// The 2D rendering context of the display canvas used for final presentation. + #[get(pub(crate))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) display_context: CanvasRenderingContext2d, /// The offscreen canvas used for high-resolution rendering. + #[get(pub(crate))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) offscreen_canvas: HtmlCanvasElement, - /// The 2D rendering context of the offscreen canvas, pre-scaled by `scale_factor`. - pub(crate) offscreen_context: CanvasRenderingContext2d, - /// The supersampling scale factor (e.g., 2.0 means 4x SSAA). - #[get(type(copy))] - pub(crate) scale_factor: f64, + /// The 2D rendering context of the offscreen canvas, pre-scaled by the + /// supersampling factor. + #[get_mut(pub(crate))] + #[set(pub(crate))] + pub offscreen_context: CanvasRenderingContext2d, /// The rendering quality preset for the downscaling present step. /// /// Controls the smoothing strategy when the offscreen buffer is /// downscaled onto the display canvas. Defaults to `Medium`. #[new(skip)] - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) quality: RenderQuality, /// The logical display width in CSS pixels. #[get(type(copy))] - pub(crate) width: f64, + #[get_mut(pub(crate))] + #[set(pub(crate))] + pub width: f64, /// The logical display height in CSS pixels. #[get(type(copy))] - pub(crate) height: f64, + #[get_mut(pub(crate))] + #[set(pub(crate))] + pub height: f64, } diff --git a/engine/src/renderer/descriptor/struct.rs b/engine/src/renderer/descriptor/struct.rs index 551ee437..31573c31 100644 --- a/engine/src/renderer/descriptor/struct.rs +++ b/engine/src/renderer/descriptor/struct.rs @@ -12,19 +12,31 @@ use super::*; #[derive(Clone, Copy, Data, Debug, New, PartialEq)] pub struct ViewportDescriptor { /// X coordinate of the viewport's top-left in pixels. - pub(crate) x: f32, + #[get(type(copy))] + #[get_mut(pub(crate))] + pub x: f32, /// Y coordinate of the viewport's top-left in pixels. - pub(crate) y: f32, + #[get(type(copy))] + pub y: f32, /// Viewport width in pixels. - pub(crate) width: f32, + #[get(type(copy))] + #[set(pub(crate))] + #[get_mut(pub(crate))] + pub width: f32, /// Viewport height in pixels. - pub(crate) height: f32, + #[get(type(copy))] + #[set(pub(crate))] + #[get_mut(pub(crate))] + pub height: f32, /// Minimum depth, clamped to `[0, 1]`. Set to `0.0` to disable. #[new(skip)] - pub(crate) min_depth: f32, + #[get(pub(crate), type(copy))] + pub min_depth: f32, /// Maximum depth, clamped to `[0, 1]`. Set to `1.0` to disable. #[new(skip)] - pub(crate) max_depth: f32, + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + pub max_depth: f32, } /// A single vertex attribute within a vertex buffer layout. @@ -36,17 +48,17 @@ pub struct ViewportDescriptor { #[derive(Clone, Copy, Debug, Eq, Getter, Hash, New, PartialEq)] pub struct VertexAttribute { /// The shader location the attribute maps to. - #[get(type(copy))] + #[get(pub(crate), type(copy))] pub(crate) shader_location: u32, /// The byte offset from the start of the vertex. #[get(type(copy))] - pub(crate) offset: u64, + pub offset: u64, /// The in-memory layout of the attribute, as one of the /// [`VertexAttributeFormat`] variants. Naming it turns a wrong /// format string into a compile error rather than a pipeline /// validation failure at creation time. #[get(type(copy))] - pub(crate) format: VertexAttributeFormat, + pub format: VertexAttributeFormat, } /// The layout of a single vertex buffer, expressed as an array stride plus @@ -58,14 +70,15 @@ pub struct VertexAttribute { #[derive(Clone, Debug, Getter, New)] pub struct VertexBufferLayout { /// The byte stride of one vertex in the buffer. - #[get(type(copy))] + #[get(pub(crate), type(copy))] pub(crate) array_stride: u64, /// Whether the buffer should be advanced per-instance (`true`) or /// per-vertex (`false`). - #[get(type(copy))] + #[get(pub(crate), type(copy))] pub(crate) step_mode: VertexStepMode, /// The attributes that describe how to interpret the bytes of one /// vertex. + #[get(pub(crate))] pub(crate) attributes: Vec, } @@ -80,21 +93,31 @@ pub struct VertexBufferLayout { pub struct Texture2DDescriptor { /// The texture width in pixels. Must be > 0. #[get(type(copy))] - pub(crate) width: u32, + #[set(pub(crate))] + #[get_mut(pub(crate))] + pub width: u32, /// The texture height in pixels. Must be > 0. #[get(type(copy))] - pub(crate) height: u32, + #[set(pub(crate))] + #[get_mut(pub(crate))] + pub height: u32, /// The texel format, as one of the [`GpuTextureFormat`] variants. #[get(type(copy))] - pub(crate) format: GpuTextureFormat, + #[set(pub(crate))] + #[get_mut(pub(crate))] + pub format: GpuTextureFormat, /// The number of mip levels. `0` is treated as `1`. #[get(type(copy))] #[new(skip)] - pub(crate) mip_level_count: u32, + #[set(pub(crate))] + #[get_mut(pub(crate))] + pub mip_level_count: u32, /// The number of samples per texel (`1` for non-MSAA, `4` for MSAA). #[get(type(copy))] #[new(skip)] - pub(crate) sample_count: u32, + #[set(pub(crate))] + #[get_mut(pub(crate))] + pub sample_count: u32, } /// Descriptor for `GpuTexture.createView(descriptor)`. @@ -109,34 +132,34 @@ pub struct TextureViewDescriptor { /// View format override, or `None` to use the texture's own format. #[get(type(clone))] #[new(value = "None")] - pub(crate) format: Option<&'static str>, + pub format: Option<&'static str>, /// View dimension (`"2d"`, `"2d-array"`, `"cube"`, `"cube-array"`, ...). /// `None` means the dimension is inferred from the texture. #[get(type(clone))] #[new(value = "None")] - pub(crate) dimension: Option<&'static str>, + pub dimension: Option<&'static str>, /// Most significant mip level (inclusive). `None` → `0`. #[get(type(copy))] #[new(value = "0")] - pub(crate) base_mip_level: u32, + pub base_mip_level: u32, /// Number of mip levels in the view. `0` → all the way to the top. #[get(type(copy))] #[new(value = "0")] - pub(crate) mip_level_count: u32, + pub mip_level_count: u32, /// First array layer (inclusive). `None` → `0`. Only meaningful for /// `2d-array` / `cube` / `cube-array` views. #[get(type(copy))] #[new(value = "0")] - pub(crate) base_array_layer: u32, + pub base_array_layer: u32, /// Number of array layers. `0` → all remaining layers. #[get(type(copy))] #[new(value = "0")] - pub(crate) array_layer_count: u32, + pub array_layer_count: u32, /// Which aspect of the texture to expose. One of: /// `"all"`, `"depth-only"`, `"stencil-only"`. `None` → `"all"`. #[get(type(clone))] #[new(value = "None")] - pub(crate) aspect: Option<&'static str>, + pub aspect: Option<&'static str>, } /// Descriptor for `queue.writeTexture(destination, data, dataLayout, size)`. @@ -149,29 +172,29 @@ pub struct TextureWriteDescriptor { /// The pixel data to upload. Bytes are laid out according to /// `bytes_per_row` / `rows_per_image`. #[get(type(clone))] - pub(crate) data: Vec, + pub data: Vec, /// Bytes per row of the source data. Must be a multiple of 256. #[get(type(copy))] - pub(crate) bytes_per_row: u32, + pub bytes_per_row: u32, /// Number of rows per image. `0` for 2D textures without mip chains. #[get(type(copy))] - pub(crate) rows_per_image: u32, + pub rows_per_image: u32, /// Destination mip level to write into. #[get(type(copy))] - pub(crate) mip_level: u32, + pub mip_level: u32, /// Destination texture to write into. #[get(type(clone))] - pub(crate) texture: JsValue, + pub texture: JsValue, /// Origin within the destination texture. `None` → `(0, 0, 0)`. #[get(type(clone))] #[new(value = "None")] - pub(crate) origin: Option, + pub origin: Option, /// Whether to flip the source data vertically before writing. /// `true` is essential when uploading from `` / `` whose /// rows are top-to-bottom but WebGPU textures are bottom-to-top. #[get(type(copy))] #[new(value = "false")] - pub(crate) flip_y: bool, + pub flip_y: bool, } /// A single entry inside a `GpuBindGroupLayoutDescriptor`. @@ -209,31 +232,40 @@ pub struct SamplerDescriptor { /// the magnification and the minification filter. #[get(type(copy))] #[new(skip)] - pub(crate) filter: FilterMode, + #[get_mut(pub(crate))] + pub filter: FilterMode, /// How results from adjacent mip levels are blended together. #[get(type(copy))] #[new(skip)] - pub(crate) mipmap_filter: MipmapFilter, + #[get_mut(pub(crate))] + pub mipmap_filter: MipmapFilter, /// What sampling does with U coordinates outside `[0, 1]`. #[get(type(copy))] #[new(skip)] - pub(crate) address_mode_u: AddressMode, + #[get_mut(pub(crate))] + pub address_mode_u: AddressMode, /// What sampling does with V coordinates outside `[0, 1]`. #[get(type(copy))] #[new(skip)] - pub(crate) address_mode_v: AddressMode, + #[set(pub(crate))] + #[get_mut(pub(crate))] + pub address_mode_v: AddressMode, /// What sampling does with W coordinates outside `[0, 1]`. Only /// meaningful for 3D textures. #[get(type(copy))] #[new(skip)] - pub(crate) address_mode_w: AddressMode, + #[set(pub(crate))] + #[get_mut(pub(crate))] + pub address_mode_w: AddressMode, /// The depth comparison to apply, or `None` for a plain filtering /// sampler that performs no comparison. Strictly more expressive than /// the old `compare: bool`, which could only mean "compare with the /// single hardcoded comparison". #[get(type(copy))] #[new(skip)] - pub(crate) compare: Option, + #[set(pub(crate))] + #[get_mut(pub(crate))] + pub compare: Option, } /// One color attachment of a render pass. @@ -287,6 +319,10 @@ pub struct DepthStencilAttachment { pub depth_store_op: StoreOp, /// Whether the pass reads depth without writing it. `true` lets the /// driver skip the depth write entirely. + /// + /// `get_depth_read_only` is `type(copy)`: a `bool` getter must hand back + /// the value, not `&bool` (§17.8.1), so every call site uses it directly. + #[get(type(copy))] pub depth_read_only: bool, } @@ -297,15 +333,21 @@ pub struct BlendComponent { /// How the two scaled terms are combined. #[get(type(copy))] #[new(skip)] - pub(crate) operation: BlendOperation, + #[set(pub(crate))] + #[get_mut(pub(crate))] + pub operation: BlendOperation, /// The factor the incoming value is multiplied by. #[get(type(copy))] #[new(skip)] - pub(crate) source: BlendFactor, + #[set(pub(crate))] + #[get_mut(pub(crate))] + pub source: BlendFactor, /// The factor the existing value is multiplied by. #[get(type(copy))] #[new(skip)] - pub(crate) destination: BlendFactor, + #[set(pub(crate))] + #[get_mut(pub(crate))] + pub destination: BlendFactor, } /// The full blend state of a single color target. @@ -319,12 +361,16 @@ pub struct BlendState { /// The blend applied to the RGB channels. #[get(type(copy))] #[new(skip)] - pub(crate) color: BlendComponent, + #[set(pub(crate))] + #[get_mut(pub(crate))] + pub color: BlendComponent, /// The blend applied to the alpha channel, which is frequently not /// the same as the color blend. #[get(type(copy))] #[new(skip)] - pub(crate) alpha: BlendComponent, + #[set(pub(crate))] + #[get_mut(pub(crate))] + pub alpha: BlendComponent, } /// One entry of a fragment stage's `targets` array: the format of that @@ -335,19 +381,25 @@ pub struct ColorTargetState { /// match the attachment's own format. #[get(type(copy))] #[new(skip)] - pub(crate) format: GpuTextureFormat, + #[set(pub(crate))] + #[get_mut(pub(crate))] + pub format: GpuTextureFormat, /// The blend to apply, or `None` to overwrite the target with the /// fragment's color. #[get(type(copy))] #[new(skip)] - pub(crate) blend: Option, + #[set(pub(crate))] + #[get_mut(pub(crate))] + pub blend: Option, /// Which channels the fragment stage may write, as a bitmask: /// `0x1` red, `0x2` green, `0x4` blue, `0x8` alpha. All four channels /// is `0xf`; `0xf` is the right value for almost every opaque /// fragment. #[get(type(copy))] #[new(skip)] - pub(crate) write_mask: u32, + #[set(pub(crate))] + #[get_mut(pub(crate))] + pub write_mask: u32, } /// The pipeline's depth-stencil state: whether depth is written, and the @@ -358,18 +410,24 @@ pub struct DepthStencilState { /// match the attachment's own format. #[get(type(copy))] #[new(skip)] - pub(crate) format: GpuTextureFormat, + #[set(pub(crate))] + #[get_mut(pub(crate))] + pub format: GpuTextureFormat, /// Whether a fragment that passes the test updates the depth buffer. /// `false` is correct for a pipeline that only reads depth, such as a /// transparent overlay drawn after the opaque pass. #[get(type(copy))] #[new(skip)] - pub(crate) depth_write_enabled: bool, + #[set(pub(crate))] + #[get_mut(pub(crate))] + pub depth_write_enabled: bool, /// The comparison performed between the fragment's depth and the /// stored depth. #[get(type(copy))] #[new(skip)] - pub(crate) depth_compare: CompareFunction, + #[set(pub(crate))] + #[get_mut(pub(crate))] + pub depth_compare: CompareFunction, } /// The pipeline's multisample state: how many samples each pixel holds and @@ -381,12 +439,16 @@ pub struct MultisampleState { /// attachment the pipeline draws into. #[get(type(copy))] #[new(skip)] - pub(crate) count: u32, + #[set(pub(crate))] + #[get_mut(pub(crate))] + pub count: u32, /// Which samples are written, as a bitmask. Leave at `0xf` to write /// all four samples of a 4x pipeline. #[get(type(copy))] #[new(skip)] - pub(crate) mask: u32, + #[set(pub(crate))] + #[get_mut(pub(crate))] + pub mask: u32, } /// The pipeline's primitive-assembly state: how vertices become @@ -394,26 +456,36 @@ pub struct MultisampleState { #[derive(Clone, Copy, Data, Debug, Default, New)] pub struct PrimitiveState { /// How vertices are assembled into primitives. - #[get(type(copy))] + #[get(pub(crate), type(copy))] #[new(skip)] + #[set(pub(crate))] + #[get_mut(pub(crate))] pub(crate) topology: PrimitiveTopology, /// Which winding order counts as front facing. #[get(type(copy))] #[new(skip)] - pub(crate) front_face: FrontFace, + #[set(pub(crate))] + #[get_mut(pub(crate))] + pub front_face: FrontFace, /// Which faces are discarded before rasterization. - #[get(type(copy))] + #[get(pub(crate), type(copy))] #[new(skip)] + #[set(pub(crate))] + #[get_mut(pub(crate))] pub(crate) cull_mode: CullMode, /// The index format for a strip topology, or `None` for a /// non-stripped topology where the field does not apply. - #[get(type(copy))] + #[get(pub(crate), type(copy))] #[new(skip)] + #[set(pub(crate))] + #[get_mut(pub(crate))] pub(crate) strip_index_format: Option, /// Whether fragments outside the depth range of `0.0..=1.0` survive. /// `false`, the default, discards them. - #[get(type(copy))] + #[get(pub(crate), type(copy))] #[new(skip)] + #[set(pub(crate))] + #[get_mut(pub(crate))] pub(crate) unclipped_depth: bool, } @@ -423,12 +495,13 @@ pub struct PrimitiveState { pub struct VertexState { /// The `GpuShaderModule` compiled from WGSL source. #[get(type(clone))] - pub(crate) module: JsValue, + pub module: JsValue, /// The `@vertex` entry point's name inside that module. #[get(type(clone))] - pub(crate) entry_point: String, + pub entry_point: String, /// The layouts of the vertex buffers this stage reads. The i-th entry /// matches the i-th `setVertexBuffer(i, ...)` call. + #[get(pub(crate))] pub(crate) buffers: Vec, } @@ -438,11 +511,12 @@ pub struct VertexState { pub struct FragmentState { /// The `GpuShaderModule` compiled from WGSL source. #[get(type(clone))] - pub(crate) module: JsValue, + pub module: JsValue, /// The `@fragment` entry point's name inside that module. #[get(type(clone))] - pub(crate) entry_point: String, + pub entry_point: String, /// One entry per color attachment, in attachment order. + #[get(pub(crate))] pub(crate) targets: Vec, } @@ -460,16 +534,21 @@ pub struct FragmentState { #[derive(Clone, Debug, Getter, New)] pub struct RenderPipelineDescriptor { /// The vertex stage, and the only stage a pipeline cannot do without. + #[get(pub(crate))] pub(crate) vertex: VertexState, /// How vertices are assembled and which faces are discarded. + #[get(pub(crate))] pub(crate) primitive: PrimitiveState, /// The depth-stencil state, or `None` for a pipeline that neither /// reads nor writes depth. + #[get(pub(crate))] pub(crate) depth_stencil: Option, /// The multisample state, which must match the attachments. + #[get(pub(crate))] pub(crate) multisample: MultisampleState, /// The fragment stage, or `None` for a pipeline that writes no color /// — a depth-only prepass, for instance. + #[get(pub(crate))] pub(crate) fragment: Option, } @@ -482,10 +561,10 @@ pub struct RenderPipelineDescriptor { pub struct ComputePipelineDescriptor { /// The `GpuShaderModule` compiled from WGSL source. #[get(type(clone))] - pub(crate) module: JsValue, + pub module: JsValue, /// The `@compute` entry point's name inside that module. #[get(type(clone))] - pub(crate) entry_point: String, + pub entry_point: String, } /// The general texture constructor, covering 2D, 2D-array, and 3D @@ -502,40 +581,57 @@ pub struct ComputePipelineDescriptor { pub struct TextureDescriptor { /// The width in texels. Must be greater than zero. #[get(type(copy))] - pub(crate) width: u32, + #[set(pub(crate))] + #[get_mut(pub(crate))] + pub width: u32, /// The height in texels. Must be greater than zero. #[get(type(copy))] - pub(crate) height: u32, + #[set(pub(crate))] + #[get_mut(pub(crate))] + pub height: u32, /// The number of array layers for a `2d-array` texture, or the depth /// in texels for a `3d` texture. `1` for a plain 2D texture. #[get(type(copy))] #[new(skip)] - pub(crate) depth_or_layers: u32, + #[get_mut(pub(crate))] + pub depth_or_layers: u32, /// The texture's dimensionality. Defaults to `"2d"`; a /// `"2d-array"` or `"3d"` texture needs this set explicitly. #[get(type(clone))] - pub(crate) dimension: &'static str, + #[set(pub(crate))] + #[get_mut(pub(crate))] + pub dimension: &'static str, /// The texel format. #[get(type(copy))] - pub(crate) format: GpuTextureFormat, + #[set(pub(crate))] + #[get_mut(pub(crate))] + pub format: GpuTextureFormat, /// The legal uses of this texture, as a `usage` bitmask. Combine /// several [`TextureUsage`] values with `|`. #[get(type(copy))] - pub(crate) usage: u32, + #[set(pub(crate))] + #[get_mut(pub(crate))] + pub usage: u32, /// The number of mip levels. Zero is treated as one. #[get(type(copy))] #[new(skip)] - pub(crate) mip_level_count: u32, + #[set(pub(crate))] + #[get_mut(pub(crate))] + pub mip_level_count: u32, /// The samples per texel: `1` for an ordinary texture, `4` for a /// multisampled render target. #[get(type(copy))] #[new(skip)] - pub(crate) sample_count: u32, + #[set(pub(crate))] + #[get_mut(pub(crate))] + pub sample_count: u32, /// A debug label shown for this texture in the browser's GPU /// tooling, or `None` to leave it unnamed. #[get(type(clone))] #[new(skip)] - pub(crate) label: Option, + #[set(pub(crate))] + #[get_mut(pub(crate))] + pub label: Option, } /// A buffer constructor, naming both the size and the legal uses of a @@ -547,15 +643,15 @@ pub struct TextureDescriptor { #[derive(Clone, Debug, Getter, New)] pub(crate) struct BufferDescriptor { /// The size in bytes. Must be greater than zero. - #[get(type(copy))] + #[get(pub(crate), type(copy))] pub(crate) size: u64, /// The legal uses of this buffer, as a `usage` bitmask. Combine /// several [`BufferUsage`] values with `|`. - #[get(type(copy))] + #[get(pub(crate), type(copy))] pub(crate) usage: u32, /// A debug label shown for this buffer in the browser's GPU tooling, /// or `None` to leave it unnamed. - #[get(type(clone))] + #[get(pub(crate), type(clone))] #[new(skip)] pub(crate) label: Option, } @@ -574,11 +670,15 @@ pub struct UniformSlice { /// The spec requires this to be a multiple of the binding's minimum /// uniform buffer offset alignment, typically 256. #[get(type(copy))] - pub(crate) offset: u64, + #[set(pub(crate))] + #[get_mut(pub(crate))] + pub offset: u64, /// The size in bytes of this record. A size of zero means "from this /// offset to the end of the bound range". #[get(type(copy))] - pub(crate) size: u64, + #[set(pub(crate))] + #[get_mut(pub(crate))] + pub size: u64, } /// The five arguments of an indexed draw, as one value. @@ -593,27 +693,32 @@ pub struct DrawIndexedArgs { /// The number of indices to read from the bound index buffer. #[get(type(copy))] #[new(skip)] - pub(crate) index_count: u32, + #[get_mut(pub(crate))] + pub index_count: u32, /// How many instances of the indexed geometry to draw. `1` draws a /// single instance. #[get(type(copy))] #[new(skip)] - pub(crate) instance_count: u32, + #[get_mut(pub(crate))] + pub instance_count: u32, /// The index of the first index to read, for drawing a sub-range of /// the index buffer. #[get(type(copy))] #[new(skip)] - pub(crate) first_index: u32, + #[get_mut(pub(crate))] + pub first_index: u32, /// The value added to every index before it is used to fetch a vertex. /// Lets several meshes share one vertex buffer. #[get(type(copy))] #[new(skip)] - pub(crate) base_vertex: i32, + #[get_mut(pub(crate))] + pub base_vertex: i32, /// The first instance index, for reading per-instance attributes from /// an offset. #[get(type(copy))] #[new(skip)] - pub(crate) first_instance: u32, + #[get_mut(pub(crate))] + pub first_instance: u32, } /// The four arguments of a non-indexed draw, as one value. @@ -627,22 +732,26 @@ pub struct DrawArgs { /// as a non-indexed stream. #[get(type(copy))] #[new(skip)] - pub(crate) vertex_count: u32, + #[get_mut(pub(crate))] + pub vertex_count: u32, /// How many instances of the geometry to draw. `1` draws a single /// instance. #[get(type(copy))] #[new(skip)] - pub(crate) instance_count: u32, + #[get_mut(pub(crate))] + pub instance_count: u32, /// The vertex to start from, for drawing a sub-range of the vertex /// stream. #[get(type(copy))] #[new(skip)] - pub(crate) first_vertex: u32, + #[get_mut(pub(crate))] + pub first_vertex: u32, /// The first instance index, for reading per-instance attributes from /// an offset. #[get(type(copy))] #[new(skip)] - pub(crate) first_instance: u32, + #[get_mut(pub(crate))] + pub first_instance: u32, } /// The three workgroup counts of a compute dispatch, as one value. @@ -655,13 +764,16 @@ pub struct DispatchArgs { /// The workgroups dispatched along the x axis. #[get(type(copy))] #[new(skip)] - pub(crate) x: u32, + #[get_mut(pub(crate))] + pub x: u32, /// The workgroups dispatched along the y axis. #[get(type(copy))] #[new(skip)] - pub(crate) y: u32, + #[get_mut(pub(crate))] + pub y: u32, /// The workgroups dispatched along the z axis. #[get(type(copy))] #[new(skip)] - pub(crate) z: u32, + #[get_mut(pub(crate))] + pub z: u32, } diff --git a/engine/src/renderer/webgl/impl.rs b/engine/src/renderer/webgl/impl.rs index b653480b..8d26d123 100644 --- a/engine/src/renderer/webgl/impl.rs +++ b/engine/src/renderer/webgl/impl.rs @@ -759,10 +759,10 @@ impl GlTexture { format: GpuTextureFormat, data: &[u8], ) -> bool { - let x: u32 = (*region.get_x()).max(0) as u32; - let y: u32 = (*region.get_y()).max(0) as u32; - let width: u32 = (*region.get_width()).max(0) as u32; - let height: u32 = (*region.get_height()).max(0) as u32; + let x: u32 = (region.get_x()).max(0) as u32; + let y: u32 = (region.get_y()).max(0) as u32; + let width: u32 = (region.get_width()).max(0) as u32; + let height: u32 = (region.get_height()).max(0) as u32; let inside: bool = x.saturating_add(width) <= self.get_width() && y.saturating_add(height) <= self.get_height(); if !inside { @@ -1160,7 +1160,6 @@ impl GlProgram { Ok(GlProgram { program, uniforms: HashMap::new(), - matrix_scratch: [0.0; GL_MAT4_FLOATS], blocks: HashMap::new(), }) } @@ -1915,10 +1914,10 @@ impl WebGl2Backend { true, ); context.scissor( - *rectangle.get_x(), - *rectangle.get_y(), - *rectangle.get_width(), - *rectangle.get_height(), + rectangle.get_x(), + rectangle.get_y(), + rectangle.get_width(), + rectangle.get_height(), ); } None => { @@ -1932,10 +1931,10 @@ impl WebGl2Backend { } if current.viewport != state.viewport { context.viewport( - *state.viewport.get_x(), - *state.viewport.get_y(), - *state.viewport.get_width(), - *state.viewport.get_height(), + state.viewport.get_x(), + state.viewport.get_y(), + state.viewport.get_width(), + state.viewport.get_height(), ); } self.set_shadow(*state); diff --git a/engine/src/renderer/webgl/struct.rs b/engine/src/renderer/webgl/struct.rs index 35f5dfb4..98b0aa3a 100644 --- a/engine/src/renderer/webgl/struct.rs +++ b/engine/src/renderer/webgl/struct.rs @@ -12,14 +12,21 @@ use super::*; #[derive(Clone, Data, Debug)] pub struct GlBuffer { /// The GL buffer object. + #[get(pub(crate))] + #[set(pub(crate))] + #[get_mut(pub(crate))] pub(crate) buffer: WebGlBuffer, /// The byte capacity the driver last allocated for this buffer. #[get(type(copy))] - pub(crate) capacity: u32, + #[set(pub(crate))] + #[get_mut(pub(crate))] + pub capacity: u32, /// The GL usage hint passed at allocation: `STATIC_DRAW` for data /// uploaded once, `DYNAMIC_DRAW` for data rewritten every frame. #[get(type(copy))] - pub(crate) usage: u32, + #[set(pub(crate))] + #[get_mut(pub(crate))] + pub usage: u32, } /// A GPU texture holding sampled image data or render-target contents. @@ -33,20 +40,30 @@ pub struct GlBuffer { #[derive(Clone, Data, Debug)] pub struct GlTexture { /// The GL texture object. - pub(crate) texture: WebGlTexture, + #[set(pub(crate))] + #[get_mut(pub(crate))] + pub texture: WebGlTexture, /// The width in texels the texture was allocated with. #[get(type(copy))] - pub(crate) width: u32, + #[set(pub(crate))] + #[get_mut(pub(crate))] + pub width: u32, /// The height in texels the texture was allocated with. #[get(type(copy))] - pub(crate) height: u32, + #[set(pub(crate))] + #[get_mut(pub(crate))] + pub height: u32, /// The number of mip levels allocated. #[get(type(copy))] - pub(crate) levels: u32, + #[set(pub(crate))] + #[get_mut(pub(crate))] + pub levels: u32, /// Whether `generateMipmap` was called, so a minification filter that /// samples across levels is known to be legal. #[get(type(copy))] - pub(crate) mipmapped: bool, + #[set(pub(crate))] + #[get_mut(pub(crate))] + pub mipmapped: bool, } /// A vertex array object: the recorded bundle of buffer bindings and @@ -59,6 +76,9 @@ pub struct GlTexture { #[derive(Clone, Data, Debug)] pub struct GlVertexArray { /// The GL vertex array object. + #[get(pub(crate))] + #[set(pub(crate))] + #[get_mut(pub(crate))] pub(crate) vao: WebGlVertexArrayObject, } @@ -71,24 +91,35 @@ pub struct GlVertexArray { #[derive(Clone, Data, Debug)] pub struct GlFramebuffer { /// The GL framebuffer object. + #[get(pub(crate))] + #[set(pub(crate))] + #[get_mut(pub(crate))] pub(crate) framebuffer: WebGlFramebuffer, /// The depth renderbuffer, optionally packed with an 8-bit stencil, /// or `None` for a color-only target such as a post-process pass /// that reads depth from a texture instead of writing its own. #[get(type(clone))] - pub(crate) depth: Option, + #[set(pub(crate))] + #[get_mut(pub(crate))] + pub depth: Option, /// The color attachment, retained so a later pass can sample the /// rendered result without a separate resolve. #[get(type(clone))] - pub(crate) color: GlTexture, + #[set(pub(crate))] + #[get_mut(pub(crate))] + pub color: GlTexture, /// The width every attachment was allocated with, re-checked on /// rebind so a window resize re-allocates rather than leaving a /// frame whose attachments disagree on size. #[get(type(copy))] - pub(crate) width: u32, + #[set(pub(crate))] + #[get_mut(pub(crate))] + pub width: u32, /// The height every attachment was allocated with. #[get(type(copy))] - pub(crate) height: u32, + #[set(pub(crate))] + #[get_mut(pub(crate))] + pub height: u32, } /// A linked GLSL program together with its cached uniform locations. @@ -102,18 +133,21 @@ pub struct GlFramebuffer { #[derive(Clone, Data, Debug)] pub struct GlProgram { /// The GL program object. + #[get(pub(crate))] + #[set(pub(crate))] + #[get_mut(pub(crate))] pub(crate) program: WebGlProgram, /// Uniform locations resolved so far, keyed by uniform name. A /// `None` entry records a uniform the compiler removed, so the /// negative result is not recomputed every frame either. - #[get(type(clone))] + #[get(pub(crate), type(clone))] + #[set(pub(crate))] + #[get_mut(pub(crate))] pub(crate) uniforms: HashMap>, - /// The reusable 16-float scratch slice every `mat4` upload goes - /// through. A fixed-size array rather than a `Vec` so uploading a - /// [`Matrix4x4`] cannot allocate at all, not even on the first call. - pub(crate) matrix_scratch: [f32; GL_MAT4_FLOATS], /// The uniform block indices resolved so far, keyed by block name. - #[get(type(clone))] + #[get(pub(crate), type(clone))] + #[set(pub(crate))] + #[get_mut(pub(crate))] pub(crate) blocks: HashMap, } @@ -127,14 +161,21 @@ pub struct GlProgram { #[derive(Clone, Data, Debug)] pub struct GlUniformBlock { /// The GL buffer holding the block's records. + #[get(pub(crate))] + #[set(pub(crate))] + #[get_mut(pub(crate))] pub(crate) buffer: WebGlBuffer, /// The binding point index the shader's `layout(binding = N)` /// qualifier agrees with. #[get(type(copy))] - pub(crate) binding: u32, + #[set(pub(crate))] + #[get_mut(pub(crate))] + pub binding: u32, /// The byte capacity allocated for the block. #[get(type(copy))] - pub(crate) capacity: u32, + #[set(pub(crate))] + #[get_mut(pub(crate))] + pub capacity: u32, } /// The blend and color-write state of one draw. @@ -147,12 +188,19 @@ pub struct GlUniformBlock { #[derive(Clone, Copy, Data, Debug, Default, PartialEq)] pub struct GlBlendState { /// Whether `BLEND` is enabled for this draw. - pub(crate) enabled: bool, + #[get(type(copy))] + #[set(pub(crate))] + #[get_mut(pub(crate))] + pub enabled: bool, /// The blend applied to the RGB channels. - pub(crate) color: BlendComponent, + #[set(pub(crate))] + #[get_mut(pub(crate))] + pub color: BlendComponent, /// The blend applied to the alpha channel, which is frequently not /// the same blend as the color channels. - pub(crate) alpha: BlendComponent, + #[set(pub(crate))] + #[get_mut(pub(crate))] + pub alpha: BlendComponent, } /// The depth-test state of one draw. @@ -166,10 +214,18 @@ pub struct GlBlendState { #[derive(Clone, Copy, Data, Debug, New, PartialEq)] pub struct GlDepthState { /// Whether `DEPTH_TEST` is enabled. - pub(crate) enabled: bool, + #[get(type(copy))] + #[set(pub(crate))] + #[get_mut(pub(crate))] + pub enabled: bool, /// The comparison between the fragment's depth and the stored depth. - pub(crate) compare: CompareFunction, + #[set(pub(crate))] + #[get_mut(pub(crate))] + pub compare: CompareFunction, /// Whether a fragment that passes the test updates the depth buffer. + #[get(pub(crate), type(copy))] + #[set(pub(crate))] + #[get_mut(pub(crate))] pub(crate) write_enabled: bool, } @@ -177,10 +233,14 @@ pub struct GlDepthState { #[derive(Clone, Copy, Data, Debug, Default, PartialEq)] pub struct GlCullState { /// Which faces are discarded before rasterization. - pub(crate) mode: CullMode, + #[set(pub(crate))] + #[get_mut(pub(crate))] + pub mode: CullMode, /// Which winding order counts as front facing. Flipping it swaps what /// [`CullMode::Front`] and [`CullMode::Back`] discard. - pub(crate) front_face: FrontFace, + #[set(pub(crate))] + #[get_mut(pub(crate))] + pub front_face: FrontFace, } /// Which channels the fragment stage may write, as a raw four-bit mask. @@ -193,7 +253,10 @@ pub struct GlCullState { #[derive(Clone, Copy, Data, Debug, New, PartialEq)] pub struct GlColorMask { /// The four channel bits. - pub(crate) bits: u32, + #[get(type(copy))] + #[set(pub(crate))] + #[get_mut(pub(crate))] + pub bits: u32, } /// A scissor rectangle. @@ -205,13 +268,22 @@ pub struct GlColorMask { #[derive(Clone, Copy, Data, Debug, New, PartialEq)] pub struct GlScissor { /// The left edge in framebuffer pixels. - pub(crate) x: i32, + #[get(type(copy))] + #[get_mut(pub(crate))] + pub x: i32, /// The bottom edge in framebuffer pixels. - pub(crate) y: i32, + #[get(type(copy))] + pub y: i32, /// The width in framebuffer pixels. - pub(crate) width: i32, + #[get(type(copy))] + #[set(pub(crate))] + #[get_mut(pub(crate))] + pub width: i32, /// The height in framebuffer pixels. - pub(crate) height: i32, + #[get(type(copy))] + #[set(pub(crate))] + #[get_mut(pub(crate))] + pub height: i32, } /// The viewport rectangle, in framebuffer pixels. @@ -221,13 +293,22 @@ pub struct GlScissor { #[derive(Clone, Copy, Data, Debug, New, PartialEq)] pub struct GlViewport { /// The left edge in framebuffer pixels. - pub(crate) x: i32, + #[get(type(copy))] + pub x: i32, /// The bottom edge in framebuffer pixels. - pub(crate) y: i32, + #[get(type(copy))] + #[get_mut(pub(crate))] + pub y: i32, /// The width in framebuffer pixels. - pub(crate) width: i32, + #[get(type(copy))] + #[set(pub(crate))] + #[get_mut(pub(crate))] + pub width: i32, /// The height in framebuffer pixels. - pub(crate) height: i32, + #[get(type(copy))] + #[set(pub(crate))] + #[get_mut(pub(crate))] + pub height: i32, } /// The whole fixed-function state of one draw, as a single `Copy` value. @@ -243,18 +324,30 @@ pub struct GlViewport { #[derive(Clone, Copy, Data, Debug, PartialEq)] pub struct GlRenderState { /// The depth-test state. - pub(crate) depth: GlDepthState, + #[set(pub(crate))] + #[get_mut(pub(crate))] + pub depth: GlDepthState, /// The blend state. - pub(crate) blend: GlBlendState, + #[set(pub(crate))] + #[get_mut(pub(crate))] + pub blend: GlBlendState, /// The culling and winding state. - pub(crate) cull: GlCullState, + #[set(pub(crate))] + #[get_mut(pub(crate))] + pub cull: GlCullState, /// Which channels the fragment stage may write. - pub(crate) color_mask: GlColorMask, + #[set(pub(crate))] + #[get_mut(pub(crate))] + pub color_mask: GlColorMask, /// The scissor rectangle, or `None` when scissoring is off. #[get(type(copy))] - pub(crate) scissor: Option, + #[set(pub(crate))] + #[get_mut(pub(crate))] + pub scissor: Option, /// The viewport rectangle. - pub(crate) viewport: GlViewport, + #[set(pub(crate))] + #[get_mut(pub(crate))] + pub viewport: GlViewport, } /// The complete WebGL 2 backend: a context plus the three shadows that @@ -280,24 +373,40 @@ pub struct WebGl2Backend { /// [`WebGl2Backend::get_canvas`] is a plain field read and cannot /// fail (the old `context.canvas().expect(..)` could panic if the /// context was ever detached from its element). - pub(crate) canvas: HtmlCanvasElement, + #[set(pub(crate))] + #[get_mut(pub(crate))] + pub canvas: HtmlCanvasElement, /// The WebGL 2 rendering context every call goes through. - pub(crate) context: WebGl2RenderingContext, + #[set(pub(crate))] + #[get_mut(pub(crate))] + pub context: WebGl2RenderingContext, /// The last state applied, diffed against to skip redundant calls. + #[get(pub(crate))] + #[set(pub(crate))] + #[get_mut(pub(crate))] pub(crate) shadow: GlRenderState, /// The texture unit that is currently active, or /// [`GL_TEXTURE_UNIT_NONE`] when the active unit is not yet known. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[set(pub(crate))] + #[get_mut(pub(crate))] pub(crate) active_unit: u32, /// The program currently bound, compared with `Object.is`. + #[get(pub(crate))] + #[set(pub(crate))] + #[get_mut(pub(crate))] pub(crate) bound_program: JsValue, /// The color [`WebGl2Backend::begin_frame`] clears to. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[set(pub(crate))] + #[get_mut(pub(crate))] pub(crate) clear_color_field: Color, /// The reused destination for /// [`WebGl2Backend::read_pixels`](super::WebGl2Backend::read_pixels), /// grown on demand so a steady-state readback allocates nothing. #[get_mut(pub(crate))] + #[get(pub(crate))] + #[set(pub(crate))] pub(crate) readback: Vec, } @@ -317,8 +426,10 @@ pub struct WebGl2BackendInit { /// The `webgl2` context every call goes through. pub context: WebGl2RenderingContext, /// The physical pixel width of the drawing buffer. + #[get(type(copy))] pub width: u32, /// The physical pixel height of the drawing buffer. + #[get(type(copy))] pub height: u32, /// The colour `begin_frame` clears to. pub clear_color: Color, diff --git a/engine/src/renderer/webgpu/impl.rs b/engine/src/renderer/webgpu/impl.rs index b1f907d3..7efdb730 100644 --- a/engine/src/renderer/webgpu/impl.rs +++ b/engine/src/renderer/webgpu/impl.rs @@ -1047,7 +1047,7 @@ impl WebGpuRenderer { let _: Result = Reflect::set( &depth_attachment, &cached_method_name(WEBGPU_PROPERTY_DEPTH_READ_ONLY), - &JsValue::from_bool(*depth_desc.get_depth_read_only()), + &JsValue::from_bool(depth_desc.get_depth_read_only()), ); let _: Result = Reflect::set( &descriptor, @@ -3562,18 +3562,18 @@ impl WebGpuRenderer { // silently swallowed). Scalars also drop the per-call `Object` // allocation and 11 `from_str` property keys. let args: Array = Array::new_with_length(6); - args.set(0, JsValue::from_f64(*viewport.get_x() as f64)); - args.set(1, JsValue::from_f64(*viewport.get_y() as f64)); - args.set(2, JsValue::from_f64(*viewport.get_width() as f64)); - args.set(3, JsValue::from_f64(*viewport.get_height() as f64)); + args.set(0, JsValue::from_f64(viewport.get_x() as f64)); + args.set(1, JsValue::from_f64(viewport.get_y() as f64)); + args.set(2, JsValue::from_f64(viewport.get_width() as f64)); + args.set(3, JsValue::from_f64(viewport.get_height() as f64)); // Forward the descriptor's depth range instead of the spec defaults. Hardcoding // them here made `ViewportDescriptor::min_depth` / `max_depth` unreachable, so a // pass that narrowed its depth range to dodge z-fighting got the full range back // and nothing said so. A zero `max_depth` still means "unset", because `New` // skips both fields and a freshly built descriptor would otherwise ask for a // zero-width depth range that discards every fragment. - let min_depth: f64 = f64::from(*viewport.get_min_depth()); - let max_depth: f64 = match *viewport.get_max_depth() { + let min_depth: f64 = f64::from(viewport.get_min_depth()); + let max_depth: f64 = match viewport.get_max_depth() { 0.0 => WEBGPU_DEFAULT_VIEWPORT_MAX_DEPTH, depth => f64::from(depth), }; diff --git a/engine/src/renderer/webgpu/struct.rs b/engine/src/renderer/webgpu/struct.rs index 3fca3918..47c91e9f 100644 --- a/engine/src/renderer/webgpu/struct.rs +++ b/engine/src/renderer/webgpu/struct.rs @@ -18,10 +18,16 @@ pub struct WebGpuRendererInit { /// The texture format string used by the swap chain (e.g., `"bgra8unorm"`). pub format: String, /// The physical pixel width of the canvas backing store. + #[get(type(copy))] pub width: u32, /// The physical pixel height of the canvas backing store. + #[get(type(copy))] pub height: u32, /// Whether MSAA anti-aliasing is enabled for render pipelines. + /// + /// `get_width` / `get_height` / `get_antialias` are `type(copy)`: a + /// primitive getter hands back the value, not a reference (§17.8.1). + #[get(type(copy))] pub antialias: bool, } @@ -38,22 +44,36 @@ pub struct WebGpuRendererInit { #[derive(Clone, Data)] pub struct WebGpuRenderer { /// The WebGPU device (`GpuDevice`) used to create GPU resources. - pub(crate) device: JsValue, + #[set(pub(crate))] + #[get_mut(pub(crate))] + pub device: JsValue, /// The device's command queue (`GpuQueue`) for submitting command buffers. - pub(crate) queue: JsValue, + #[set(pub(crate))] + #[get_mut(pub(crate))] + pub queue: JsValue, /// The WebGPU canvas rendering context (`GpuCanvasContext`). - pub(crate) context: JsValue, + #[set(pub(crate))] + #[get_mut(pub(crate))] + pub context: JsValue, /// The HTML canvas element backing the WebGPU context. - pub(crate) canvas: HtmlCanvasElement, + #[set(pub(crate))] + #[get_mut(pub(crate))] + pub canvas: HtmlCanvasElement, /// The texture format string used by the canvas's swap chain (e.g., `"bgra8unorm"`). #[get(type(clone))] - pub(crate) format: String, + #[set(pub(crate))] + #[get_mut(pub(crate))] + pub format: String, /// The physical pixel width of the canvas backing store. #[get(type(copy))] - pub(crate) width: u32, + #[set(pub(crate))] + #[get_mut(pub(crate))] + pub width: u32, /// The physical pixel height of the canvas backing store. #[get(type(copy))] - pub(crate) height: u32, + #[set(pub(crate))] + #[get_mut(pub(crate))] + pub height: u32, /// Whether MSAA anti-aliasing is enabled for render pipelines. /// /// When `true`, the renderer allocates a multisampled intermediate texture @@ -61,18 +81,24 @@ pub struct WebGpuRenderer { /// `false`, render passes attach directly to the swap chain view at /// `sampleCount: 1`. #[get(type(copy))] - pub(crate) antialias: bool, + #[set(pub(crate))] + #[get_mut(pub(crate))] + pub antialias: bool, /// The multisampled color texture used when `antialias` is `true`. /// /// `None` when MSAA is disabled. Rebuilt on every resize because the /// `width`/`height` are immutable for a given `GpuTexture`. #[get(type(clone))] - pub(crate) multisample_texture: Option, + #[set(pub(crate))] + #[get_mut(pub(crate))] + pub multisample_texture: Option, /// The default `GpuTextureView` into `multisample_texture`. /// /// Cached at texture-create time so `begin_render_pass` does not have to /// recreate the view each frame. `None` when MSAA is disabled. - #[get(type(clone))] + #[get(pub(crate), type(clone))] + #[set(pub(crate))] + #[get_mut(pub(crate))] pub(crate) multisample_view: Option, /// The depth-stencil texture used for depth-tested passes. /// @@ -82,19 +108,25 @@ pub struct WebGpuRenderer { /// matching default view is cached in `depth_view`. /// /// `None` until the first depth-tested render pass is opened. - #[get(type(clone))] + #[get(pub(crate), type(clone))] + #[set(pub(crate))] + #[get_mut(pub(crate))] pub(crate) depth_texture: Option, /// The default `GpuTextureView` into `depth_texture`. /// /// `None` when no depth texture has been allocated. - #[get(type(clone))] + #[get(pub(crate), type(clone))] + #[set(pub(crate))] + #[get_mut(pub(crate))] pub(crate) depth_view: Option, /// The depth-stencil format used for `depth_texture`. /// /// Stored so subsequent render-pass openers can pass the same format /// to the pipeline layout without having to remember it externally. /// `None` until the first depth texture is allocated. - #[get(type(clone))] + #[get(pub(crate), type(clone))] + #[set(pub(crate))] + #[get_mut(pub(crate))] pub(crate) depth_format: Option, /// User-supplied closure fired when the underlying `GpuDevice` enters /// the `lost` state (browser-initiated context loss, OS driver crash, @@ -104,7 +136,9 @@ pub struct WebGpuRenderer { /// The renderer also stores a separate `device_lost_handle` that /// forwards the `GPUDeviceLostInfo` JS value into this callback. #[get(type(clone))] - pub(crate) device_lost_callback: Option, + #[set(pub(crate))] + #[get_mut(pub(crate))] + pub device_lost_callback: Option, /// Whether the device is currently in the `lost` state. /// /// Once flipped to `true`, every GPU operation returns @@ -112,7 +146,9 @@ pub struct WebGpuRenderer { /// renderer and creates a new one (WebGPU has no "recover from lost /// device" API). #[get(type(copy))] - pub(crate) device_lost: bool, + #[set(pub(crate))] + #[get_mut(pub(crate))] + pub device_lost: bool, /// Shared slot for the most recent popped error-scope value. /// /// `device.popErrorScope()` returns a `Promise`; we @@ -136,6 +172,9 @@ pub struct WebGpuRenderer { /// confined to two call sites. This mirrors how euv-core /// implements its global registries /// (`core/src/renderer/registry/struct.rs:62`). + #[get(pub(crate))] + #[set(pub(crate))] + #[get_mut(pub(crate))] pub(crate) pending_error: Rc, /// The currently-open `GpuCommandEncoder`, if any. /// @@ -147,7 +186,9 @@ pub struct WebGpuRenderer { /// be re-acquired via `device.createCommandEncoder()` before /// the next frame. #[get(type(clone))] - pub(crate) command_encoder: Option, + #[set(pub(crate))] + #[get_mut(pub(crate))] + pub command_encoder: Option, /// OPT 34: cached render-pass descriptor, allocated lazily on the /// first call to [`WebGpuRenderer::begin_render_pass_full`]. /// @@ -161,7 +202,9 @@ pub struct WebGpuRenderer { /// /// `None` until the first `begin_render_pass_full` call; `Some(_)` /// afterwards and persists for the lifetime of the renderer. - #[get(type(clone))] + #[get(pub(crate), type(clone))] + #[set(pub(crate))] + #[get_mut(pub(crate))] pub(crate) render_pass_descriptor_cache: Option, } diff --git a/engine/src/scene/struct.rs b/engine/src/scene/struct.rs index 7202ac10..98798198 100644 --- a/engine/src/scene/struct.rs +++ b/engine/src/scene/struct.rs @@ -7,12 +7,12 @@ pub struct SceneManager { #[get_mut(pub(crate))] #[set(pub(crate))] #[new(skip)] - pub(crate) scenes: HashMap, + pub scenes: HashMap, /// The name of the currently active scene, if any. #[get_mut(pub(crate))] #[set(pub(crate))] #[new(skip)] - pub(crate) current_scene_name: Option, + pub current_scene_name: Option, /// The name of the next scene to switch to (for deferred transitions). #[get_mut(pub(crate))] #[set(pub(crate))] diff --git a/engine/src/scheduler/struct.rs b/engine/src/scheduler/struct.rs index 3264d17f..cf5cda8f 100644 --- a/engine/src/scheduler/struct.rs +++ b/engine/src/scheduler/struct.rs @@ -4,55 +4,79 @@ use super::*; #[derive(Clone, Copy, Data, Debug, New, PartialEq, PartialOrd)] pub struct SchedulerConfig { /// The fixed simulation timestep in seconds (e.g., 1/60 for 60 Hz updates). - #[get(type(copy))] + /// + /// `get_fixed_timestep` is public because tests/api_visibility/fn.rs, tests/config/fn.rs, tests/scheduler/fn.rs reads this field from + /// outside this crate. `get_mut` / `set` stay crate-internal. #[get_mut(pub(crate))] + #[get(type(copy))] #[set(pub(crate))] - pub(crate) fixed_timestep: f64, + pub fixed_timestep: f64, /// The maximum allowed frame time in seconds before the scheduler starts dropping updates. - #[get(type(copy))] + /// + /// `get_max_frame_time` is public because tests/api_visibility/fn.rs, tests/config/fn.rs reads this field from + /// outside this crate. `get_mut` / `set` stay crate-internal. #[get_mut(pub(crate))] + #[get(type(copy))] #[set(pub(crate))] - pub(crate) max_frame_time: f64, + pub max_frame_time: f64, } /// The runtime state of a scheduler instance. #[derive(Clone, Data, Debug, New, PartialEq)] pub struct SchedulerState { /// The accumulated time waiting to be processed by fixed updates. - #[get(type(copy))] + /// + /// `get_accumulator` is public because tests/api_visibility/fn.rs, tests/config/fn.rs reads this field from + /// outside this crate. `get_mut` / `set` stay crate-internal. #[get_mut(pub(crate))] + #[get(type(copy))] #[set(pub(crate))] #[new(skip)] - pub(crate) accumulator: f64, + pub accumulator: f64, /// The timestamp of the previous frame in seconds, or `UNINITIALIZED_TIME` before the first frame. - #[get(type(copy))] + /// + /// `get_last_time` is public because tests/api_visibility/fn.rs reads this field from + /// outside this crate. `get_mut` / `set` stay crate-internal. #[get_mut(pub(crate))] + #[get(type(copy))] #[set(pub(crate))] - pub(crate) last_time: f64, + pub last_time: f64, /// Whether the scheduler is currently running and scheduling animation frames. - #[get(type(copy))] + /// + /// `get_running` is public because tests/api_visibility/fn.rs, tests/config/fn.rs reads this field from + /// outside this crate. `get_mut` / `set` stay crate-internal. #[get_mut(pub(crate))] + #[get(type(copy))] #[set(pub(crate))] #[new(skip)] - pub(crate) running: bool, + pub running: bool, /// The most recent `requestAnimationFrame` ID, used to cancel the next frame. - #[get(type(copy))] + /// + /// `get_raf_id` is public because tests/api_visibility/fn.rs, tests/config/fn.rs reads this field from + /// outside this crate. `get_mut` / `set` stay crate-internal. #[get_mut(pub(crate))] + #[get(type(copy))] #[set(pub(crate))] #[new(skip)] - pub(crate) raf_id: Option, + pub raf_id: Option, /// The total number of fixed update steps executed since the scheduler started. - #[get(type(copy))] + /// + /// `get_update_count` is public because tests/api_visibility/fn.rs, tests/config/fn.rs, tests/input/fn.rs, tests/scheduler/fn.rs reads this field from + /// outside this crate. `get_mut` / `set` stay crate-internal. #[get_mut(pub(crate))] + #[get(type(copy))] #[set(pub(crate))] #[new(skip)] - pub(crate) update_count: u64, + pub update_count: u64, /// The total number of render frames executed since the scheduler started. - #[get(type(copy))] + /// + /// `get_frame_count` is public because tests/api_visibility/fn.rs, tests/config/fn.rs, tests/input/fn.rs, tests/scheduler/fn.rs reads this field from + /// outside this crate. `get_mut` / `set` stay crate-internal. #[get_mut(pub(crate))] + #[get(type(copy))] #[set(pub(crate))] #[new(skip)] - pub(crate) frame_count: u64, + pub frame_count: u64, } /// A handle to a running scheduler, allowing the caller to stop it later. @@ -62,15 +86,21 @@ pub struct SchedulerHandle { /// `UnsafeCell`-backed `Sync` newtype) so multiple closure /// captures can mutate it without `RefCell`'s runtime borrow /// check. Mirrors `core::reactive::schedule` shape. + /// + /// `get_state` is public because tests/api_visibility/fn.rs reads this field from + /// outside this crate. `get_mut` / `set` stay crate-internal. #[get_mut(pub(crate))] #[set(pub(crate))] - pub(crate) state: Rc>, + pub state: Rc>, /// The shared closure cell keeping the RAF callback alive. Held /// behind `MaybeEngineCell` because the cell is empty both /// before `spawn` runs and after cleanup tears the closure down. + /// + /// `get_closure_cell` is public because tests/api_visibility/fn.rs reads this field from + /// outside this crate. `get_mut` / `set` stay crate-internal. #[get_mut(pub(crate))] #[set(pub(crate))] - pub(crate) closure_cell: RafClosureCell, + pub closure_cell: RafClosureCell, } /// A registry of [`Updatable`] tasks driven by the fixed-timestep scheduler. @@ -108,7 +138,8 @@ pub struct TaskRegistry { #[derive(Clone, Copy, Data, Debug, New, PartialEq, PartialOrd)] pub struct TaskHandle { /// The zero-based index of the task in the registry's task list. - #[get(type(copy))] + #[get(pub(crate), type(copy))] #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) id: u64, } diff --git a/engine/src/spatial/struct.rs b/engine/src/spatial/struct.rs index 884f71c3..0eb19043 100644 --- a/engine/src/spatial/struct.rs +++ b/engine/src/spatial/struct.rs @@ -11,18 +11,18 @@ pub struct SpatialHashGrid2D { #[get(type(copy))] #[get_mut(pub(crate))] #[set(pub(crate))] - pub(crate) cell_size: f64, + pub cell_size: f64, /// The inverse of `cell_size`, precomputed for fast coordinate-to-cell hashing. - #[get(type(copy))] + #[get(type(copy), pub(crate))] #[set(pub(crate))] #[new(skip)] - #[get(pub(crate))] #[get_mut(pub(crate))] pub(crate) inverse_cell_size: f64, /// The hash map from cell key to the list of body indices occupying that cell. #[get_mut(pub(crate))] #[new(skip)] - pub(crate) cells: SpatialCellMap2D, + #[set(pub(crate))] + pub cells: SpatialCellMap2D, } /// A uniform-grid spatial hash for broad-phase collision culling in 3D. @@ -36,18 +36,18 @@ pub struct SpatialHashGrid3D { #[get(type(copy))] #[get_mut(pub(crate))] #[set(pub(crate))] - pub(crate) cell_size: f64, + pub cell_size: f64, /// The inverse of `cell_size`, precomputed for fast coordinate-to-cell hashing. - #[get(type(copy))] + #[get(type(copy), pub(crate))] #[set(pub(crate))] #[new(skip)] - #[get(pub(crate))] #[get_mut(pub(crate))] pub(crate) inverse_cell_size: f64, /// The hash map from cell key to the list of body indices occupying that cell. #[get_mut(pub(crate))] #[new(skip)] - pub(crate) cells: SpatialCellMap3D, + #[set(pub(crate))] + pub cells: SpatialCellMap3D, } /// One inserted body inside a 2D quadtree, stored with its exact @@ -61,17 +61,19 @@ pub struct SpatialHashGrid3D { pub struct QuadTreeEntry2D { /// The caller-owned body index, returned verbatim by queries. #[get(type(copy))] - pub(crate) index: usize, + #[set(pub(crate))] + #[get_mut(pub(crate))] + pub index: usize, /// The minimum corner of the body's world-space bounding box. #[get(type(copy))] #[get_mut(pub(crate))] #[set(pub(crate))] - pub(crate) min: Vector2D, + pub min: Vector2D, /// The maximum corner of the body's world-space bounding box. #[get(type(copy))] #[get_mut(pub(crate))] #[set(pub(crate))] - pub(crate) max: Vector2D, + pub max: Vector2D, } /// One node of the 2D quadtree, holding an axis-aligned region plus the @@ -87,25 +89,26 @@ pub struct QuadTreeNode2D { #[get(type(copy))] #[get_mut(pub(crate))] #[set(pub(crate))] - pub(crate) min: Vector2D, + pub min: Vector2D, /// The maximum corner of the region this node covers. #[get(type(copy))] #[get_mut(pub(crate))] #[set(pub(crate))] - pub(crate) max: Vector2D, + pub max: Vector2D, /// The subdivision level, `0` at the root and incrementing per split. #[get(type(copy))] #[get_mut(pub(crate))] #[set(pub(crate))] - pub(crate) depth: usize, + pub depth: usize, /// The child node handles, all unset until the node subdivides. #[set(pub(crate))] #[get_mut(pub(crate))] - pub(crate) children: QuadTreeChildren2D, + pub children: QuadTreeChildren2D, /// A flag that is `true` once the node owns four valid child handles. #[get(type(copy))] #[set(pub(crate))] - pub(crate) leaf: bool, + #[get_mut(pub(crate))] + pub leaf: bool, /// A flag that is `true` once this node retains an entry which is not fully /// contained by the node's own region. /// @@ -116,10 +119,13 @@ pub struct QuadTreeNode2D { /// is what keeps a body larger than the root region findable. #[get(type(copy))] #[set(pub(crate))] - pub(crate) loose: bool, + #[get_mut(pub(crate))] + pub loose: bool, /// The entries stored directly in this node, including any body that is /// too large for, or straddles the boundary of, a single child region. #[set(pub(crate))] + #[get(pub(crate))] + #[get_mut(pub(crate))] pub(crate) entries: QuadTreeEntryList2D, } @@ -142,19 +148,19 @@ pub struct QuadTree2D { /// The arena of all live nodes; node 0 is always the root. #[get_mut(pub(crate))] #[set(pub(crate))] - pub(crate) nodes: QuadTreeNodeList2D, + pub nodes: QuadTreeNodeList2D, /// The number of entries a node may hold before subdividing. #[get(type(copy))] #[get_mut(pub(crate))] - pub(crate) capacity: usize, + #[set(pub(crate))] + pub capacity: usize, /// The deepest subdivision level a node may reach. #[get(pub(crate), type(copy))] #[get_mut(pub(crate))] - #[set(pub(crate))] - pub(crate) max_depth: usize, + pub max_depth: usize, /// The total number of inserted bodies across every node. - #[get(pub(crate), type(copy))] + #[get(type(copy))] #[set(pub(crate))] #[get_mut(pub(crate))] - pub(crate) count: usize, + pub count: usize, } diff --git a/engine/src/sprite/struct.rs b/engine/src/sprite/struct.rs index 3fd96cf3..6ab0f703 100644 --- a/engine/src/sprite/struct.rs +++ b/engine/src/sprite/struct.rs @@ -5,12 +5,14 @@ use super::*; pub struct SpriteFrame { /// The source rectangle within the sprite sheet image. #[get(type(copy))] - pub(crate) source: Rect, + #[set(pub(crate))] + #[get_mut(pub(crate))] + pub source: Rect, /// The duration this frame should be displayed, in seconds. #[get(type(copy))] #[get_mut(pub(crate))] #[set(pub(crate))] - pub(crate) duration: f64, + pub duration: f64, } /// Defines a sprite sheet with uniform frame grid dimensions. @@ -19,6 +21,7 @@ pub struct SpriteSheet { /// The source image element loaded from an asset. #[get_mut(pub(crate))] #[set(pub(crate))] + #[get(pub(crate))] pub(crate) image: HtmlImageElement, /// The width of each individual frame in pixels. #[get(pub(crate), type(copy))] @@ -34,12 +37,12 @@ pub struct SpriteSheet { #[get(type(copy))] #[get_mut(pub(crate))] #[set(pub(crate))] - pub(crate) columns: u32, + pub columns: u32, /// The number of rows in the sprite sheet grid. #[get(type(copy))] #[get_mut(pub(crate))] #[set(pub(crate))] - pub(crate) rows: u32, + pub rows: u32, } /// A named sequence of frames that form an animation. @@ -48,51 +51,57 @@ pub struct SpriteAnimation { /// The name identifying this animation (e.g., `"idle"`, `"walk"`). #[get_mut(pub(crate))] #[set(pub(crate))] - pub(crate) name: String, + pub name: String, /// The ordered list of frames in this animation. - pub(crate) frames: Vec, + #[set(pub(crate))] + #[get_mut(pub(crate))] + pub frames: Vec, /// The playback mode (loop, once, ping-pong). #[get(type(copy))] #[get_mut(pub(crate))] - pub(crate) mode: AnimationMode, + #[set(pub(crate))] + pub mode: AnimationMode, } /// Manages the playback state of sprite animations. #[derive(Clone, Data, Debug, New, PartialEq)] pub struct Animator { /// The currently active animation, if any. - #[get(type(clone))] + /// + /// `get_current_animation` is public: `engine/tests/animator` inspects the + /// active clip from outside this crate. `get_mut` / `set` stay + /// crate-internal — clip changes go through the playback API. #[get_mut(pub(crate))] - #[new(skip)] + #[get(type(clone))] #[set(pub(crate))] - pub(crate) current_animation: Option, + #[new(skip)] + pub current_animation: Option, /// The index of the current frame being displayed. #[get(type(copy))] #[get_mut(pub(crate))] #[set(pub(crate))] #[new(skip)] - pub(crate) current_frame_index: usize, + pub current_frame_index: usize, /// The elapsed time within the current frame, in seconds. #[get(type(copy))] #[get_mut(pub(crate))] #[set(pub(crate))] #[new(skip)] - #[get(pub(crate))] - pub(crate) elapsed_time: f64, + pub elapsed_time: f64, /// The current playback state. #[get(type(copy))] #[get_mut(pub(crate))] - pub(crate) state: AnimationState, + #[set(pub(crate))] + pub state: AnimationState, /// The direction of playback (1 = forward, -1 = backward) for ping-pong mode. #[get(type(copy))] #[get_mut(pub(crate))] #[set(pub(crate))] - pub(crate) direction: i32, + pub direction: i32, /// Whether to flip the sprite horizontally when rendering. - #[get(type(copy))] + #[get(type(copy), pub(crate))] #[get_mut(pub(crate))] #[new(skip)] - #[get(pub(crate))] #[set(pub(crate))] pub(crate) flip_x: bool, /// Whether to flip the sprite vertically when rendering. @@ -100,7 +109,7 @@ pub struct Animator { #[get_mut(pub(crate))] #[new(skip)] #[set(pub(crate))] - pub(crate) flip_y: bool, + pub flip_y: bool, } /// The nine source or destination sub-rectangles produced by a nine-slice split. @@ -117,7 +126,7 @@ pub struct NineSliceRects { #[get(type(copy))] #[get_mut(pub(crate))] #[set(pub(crate))] - pub(crate) grid: [[Rect; 3]; 3], + pub grid: [[Rect; 3]; 3], } /// The four per-edge border insets that carve an image into nine patches. @@ -133,22 +142,22 @@ pub struct NineSliceInsets { #[get(type(copy))] #[get_mut(pub(crate))] #[set(pub(crate))] - pub(crate) left: f64, + pub left: f64, /// The right border inset in source pixels. #[get(type(copy))] #[get_mut(pub(crate))] #[set(pub(crate))] - pub(crate) right: f64, + pub right: f64, /// The top border inset in source pixels. #[get(type(copy))] #[get_mut(pub(crate))] #[set(pub(crate))] - pub(crate) top: f64, + pub top: f64, /// The bottom border inset in source pixels. #[get(type(copy))] #[get_mut(pub(crate))] #[set(pub(crate))] - pub(crate) bottom: f64, + pub bottom: f64, } /// A source image paired with the insets that split it into nine patches. @@ -162,7 +171,7 @@ pub struct NineSliceInsets { #[derive(Clone, Data, Debug, New, PartialEq)] pub struct NineSlice { /// The source image element containing the nine patches. - #[get(type(clone))] + #[get(pub(crate), type(clone))] #[get_mut(pub(crate))] #[set(pub(crate))] pub(crate) image: HtmlImageElement, @@ -184,22 +193,22 @@ pub struct UvRect { #[get(type(copy))] #[get_mut(pub(crate))] #[set(pub(crate))] - pub(crate) u0: f64, + pub u0: f64, /// The normalized top edge (V at the rectangle's top). #[get(type(copy))] #[get_mut(pub(crate))] #[set(pub(crate))] - pub(crate) v0: f64, + pub v0: f64, /// The normalized right edge (U at the rectangle's right). #[get(type(copy))] #[get_mut(pub(crate))] #[set(pub(crate))] - pub(crate) u1: f64, + pub u1: f64, /// The normalized bottom edge (V at the rectangle's bottom). #[get(type(copy))] #[get_mut(pub(crate))] #[set(pub(crate))] - pub(crate) v1: f64, + pub v1: f64, } /// The name-to-rectangle index of a sprite atlas, without the image handle. @@ -226,7 +235,7 @@ pub struct AtlasRegions { #[derive(Clone, Data, Debug, New, PartialEq)] pub struct SpriteAtlas { /// The shared image element holding every packed sprite. - #[get(type(clone))] + #[get(pub(crate), type(clone))] #[get_mut(pub(crate))] #[set(pub(crate))] pub(crate) image: HtmlImageElement, diff --git a/engine/src/timer/struct.rs b/engine/src/timer/struct.rs index e9b2c491..561ae6ab 100644 --- a/engine/src/timer/struct.rs +++ b/engine/src/timer/struct.rs @@ -11,7 +11,7 @@ pub struct Timer { #[get(type(copy))] #[set(pub(crate))] #[get_mut(pub(crate))] - pub(crate) duration: f64, + pub duration: f64, /// Whether the timer restarts automatically after firing. #[get(pub(crate), type(copy))] #[set(pub(crate))] @@ -22,18 +22,16 @@ pub struct Timer { #[get_mut(pub(crate))] #[set(pub(crate))] #[new(skip)] - pub(crate) elapsed: f64, + pub elapsed: f64, /// Whether the timer is currently paused. - #[get(type(copy))] + #[get(type(copy), pub(crate))] #[new(skip)] - #[get(pub(crate))] #[get_mut(pub(crate))] #[set(pub(crate))] pub(crate) paused: bool, /// Whether a one-shot timer has fired and stopped. - #[get(type(copy))] + #[get(type(copy), pub(crate))] #[new(skip)] - #[get(pub(crate))] #[get_mut(pub(crate))] #[set(pub(crate))] pub(crate) finished: bool, diff --git a/engine/tests/api_visibility/fn.rs b/engine/tests/api_visibility/fn.rs index 40422369..8ad2c461 100644 --- a/engine/tests/api_visibility/fn.rs +++ b/engine/tests/api_visibility/fn.rs @@ -32,8 +32,8 @@ fn render_pass_depth_stencil_attachment_externally_constructible() { assert_eq!(*load, LoadOp::Clear); let store: &StoreOp = attachment.get_depth_store_op(); assert_eq!(*store, StoreOp::Store); - let read_only: &bool = attachment.get_depth_read_only(); - assert!(!*read_only); + let read_only: bool = attachment.get_depth_read_only(); + assert!(!read_only); let view: Option<&JsValue> = attachment.try_get_view().as_ref(); assert!(view.is_none()); } diff --git a/engine/tests/audio_clip/fn.rs b/engine/tests/audio_clip/fn.rs index 3fae5e47..81ae5df9 100644 --- a/engine/tests/audio_clip/fn.rs +++ b/engine/tests/audio_clip/fn.rs @@ -26,7 +26,7 @@ fn decoded_buffer(channels: u32, duration: f64) -> AudioBuffer { } fn clip() -> AudioClip { - AudioClip::create(decoded_buffer(2, 1.5), String::from("beep")) + AudioClip::create(decoded_buffer(2, 1.5)) } #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test)] diff --git a/engine/tests/audio_prop/fn.rs b/engine/tests/audio_prop/fn.rs index 7a3e5ffc..977d4e0d 100644 --- a/engine/tests/audio_prop/fn.rs +++ b/engine/tests/audio_prop/fn.rs @@ -61,13 +61,13 @@ fn gain_node(param: &Object) -> GainNode { ignore = "stands in a decoded WebAudio buffer as a plain object" )] fn a_clip_reports_the_channel_count_of_the_buffer_it_was_built_from() { - let stereo: AudioClip = AudioClip::create(decoded_buffer(2, 1.5), String::from("stereo")); + let stereo: AudioClip = AudioClip::create(decoded_buffer(2, 1.5)); assert_eq!( stereo.channel_count(), 2, "the clip must read numberOfChannels off the buffer rather than assume mono" ); - let mono: AudioClip = AudioClip::create(decoded_buffer(1, 0.25), String::from("mono")); + let mono: AudioClip = AudioClip::create(decoded_buffer(1, 0.25)); assert_eq!( mono.channel_count(), 1, @@ -82,7 +82,7 @@ fn a_clip_reports_the_channel_count_of_the_buffer_it_was_built_from() { ignore = "stands in a decoded WebAudio buffer as a plain object" )] fn a_clip_reports_the_duration_of_the_buffer_it_was_built_from() { - let clip: AudioClip = AudioClip::create(decoded_buffer(2, 1.5), String::from("beep")); + let clip: AudioClip = AudioClip::create(decoded_buffer(2, 1.5)); assert_eq!( clip.duration(), 1.5, @@ -97,21 +97,15 @@ fn a_clip_reports_the_duration_of_the_buffer_it_was_built_from() { ignore = "stands in the WebAudio context and gain graph as plain objects" )] fn a_context_reads_the_sample_rate_off_the_browser_supplied_context() { - let context: GameAudioContext = GameAudioContext::new( - browser_context(48000.0, 0.0), - gain_node(&silent_param()), - 1.0, - ); + let context: GameAudioContext = + GameAudioContext::new(browser_context(48000.0, 0.0), gain_node(&silent_param())); assert_eq!( context.sample_rate(), 48000.0, "the sample rate is a property of the live AudioContext, not a hardcoded default" ); - let other: GameAudioContext = GameAudioContext::new( - browser_context(22050.0, 0.0), - gain_node(&silent_param()), - 1.0, - ); + let other: GameAudioContext = + GameAudioContext::new(browser_context(22050.0, 0.0), gain_node(&silent_param())); assert_eq!( other.sample_rate(), 22050.0, @@ -126,11 +120,8 @@ fn a_context_reads_the_sample_rate_off_the_browser_supplied_context() { ignore = "stands in the WebAudio context and gain graph as plain objects" )] fn a_context_reports_the_playback_time_off_the_browser_supplied_context() { - let context: GameAudioContext = GameAudioContext::new( - browser_context(48000.0, 12.5), - gain_node(&silent_param()), - 1.0, - ); + let context: GameAudioContext = + GameAudioContext::new(browser_context(48000.0, 12.5), gain_node(&silent_param())); assert_eq!( context.current_time(), 12.5, @@ -149,7 +140,6 @@ fn master_volume_is_clamped_into_the_unit_range_before_it_reaches_the_gain_node( let context: GameAudioContext = GameAudioContext::new( browser_context(48000.0, 0.0), gain_node(&recording_param(&sink)), - 1.0, ); context.apply_master_volume(2.0); @@ -181,7 +171,7 @@ fn suspending_a_context_is_routed_through_to_the_browser_context() { js_sys::Function::new_no_args("this.__suspended = true; return this;"); reflective(&holder, "suspend", suspend.as_ref()); let browser: AudioContext = holder.clone().unchecked_into(); - let context: GameAudioContext = GameAudioContext::new(browser, gain_node(&silent_param()), 1.0); + let context: GameAudioContext = GameAudioContext::new(browser, gain_node(&silent_param())); context.suspend(); diff --git a/engine/tests/lighting/fn.rs b/engine/tests/lighting/fn.rs index a4f6c9a1..9004ab5f 100644 --- a/engine/tests/lighting/fn.rs +++ b/engine/tests/lighting/fn.rs @@ -5,6 +5,8 @@ fn epsilon(actual: f64, expected: f64) -> bool { } const EPSILON: f64 = 1e-9; +type CountingVisibility = Box f64>; + #[test] fn lambert_diffuse_face_normal() { let light: Light = @@ -31,35 +33,73 @@ fn lambert_diffuse_face_normal() { } #[test] -fn phong_specular_peak() { +fn phong_specular_is_one_when_the_light_the_normal_and_the_eye_all_point_along_the_normal() { let normal: Vector3D = Vector3D::new(0.0, 1.0, 0.0); - let light_dir: Vector3D = Vector3D::new(0.0, -1.0, 0.0); - let view_dir: Vector3D = Vector3D::new(0.0, 1.0, 0.0); - let light: Light = Light::new( - LightType::Directional, - Vector3D::zero(), - light_dir, - Vector3D::new(1.0, 1.0, 1.0), - 1.0, - 0.0, - 0.0, + let color: Vector3D = Vector3D::new(1.0, 1.0, 1.0); + let light: Light = Light::new_directional(normal, color); + let material: Material = Material::phong(color, 1.0, 32.0); + let result: Vector3D = compute_phong(&light, normal, normal, &material); + assert!( + (result.get_x() - 1.0).abs() < EPSILON + && (result.get_y() - 1.0).abs() < EPSILON + && (result.get_z() - 1.0).abs() < EPSILON, + "with l, n, and v all on +y the reflection reflect(-l, n) equals n, so dot(reflect, v) is 1 and the lobe is full, got {result:?}" ); - let material: Material = Material::phong(Vector3D::new(1.0, 1.0, 1.0), 1.0, 32.0); - let result: Vector3D = compute_phong(&light, normal, view_dir, &material); +} + +#[test] +fn phong_specular_is_zero_for_a_back_facing_light_even_when_the_eye_sits_on_the_reflection() { + let normal: Vector3D = Vector3D::new(0.0, 1.0, 0.0); + let opposite_the_normal: Vector3D = Vector3D::new(0.0, -1.0, 0.0); + let color: Vector3D = Vector3D::new(1.0, 1.0, 1.0); + let material: Material = Material::phong(color, 1.0, 32.0); + let light: Light = Light::new_directional(opposite_the_normal, color); + let eye_on_the_reflection: Vector3D = + compute_phong(&light, normal, opposite_the_normal, &material); assert!( - (result.get_x() - 1.0).abs() < EPSILON, - "expected specular peak ~1.0, got {}", - result.get_x(), + eye_on_the_reflection.get_x().abs() < EPSILON + && eye_on_the_reflection.get_y().abs() < EPSILON + && eye_on_the_reflection.get_z().abs() < EPSILON, + "dot(n, l) is -1 yet reflect(-l, n) equals -n and the eye sits on -n, so dot(reflect, v) would be 1 without the NdotL > 0 gate; the gate alone must force zero, got {eye_on_the_reflection:?}" ); + let eye_in_front: Vector3D = compute_phong(&light, normal, normal, &material); assert!( - (result.get_y() - 1.0).abs() < EPSILON, - "expected specular peak ~1.0, got {}", - result.get_y(), + eye_in_front.get_x().abs() < EPSILON, + "the same back-facing light seen by an eye in front is zero by the dot(reflect, v) clamp instead of the facing gate, got {}", + eye_in_front.get_x(), ); +} + +#[test] +fn phong_highlight_peaks_at_the_reflection_of_the_light_and_not_at_the_half_vector() { + let normal: Vector3D = Vector3D::new(0.0, 1.0, 0.0); + let color: Vector3D = Vector3D::new(1.0, 1.0, 1.0); + let light_dir: Vector3D = Vector3D::new(0.6, 0.8, 0.0); + let light: Light = Light::new_directional(light_dir, color); + let shininess: f64 = 2.0; + let material: Material = Material::phong(color, 1.0, shininess); + let reflected: Vector3D = Vector3D::new(-0.6, 0.8, 0.0); assert!( - (result.get_z() - 1.0).abs() < EPSILON, - "expected specular peak ~1.0, got {}", - result.get_z(), + (reflected.get_x().powf(2.0) + reflected.get_y().powf(2.0) - 1.0).abs() < EPSILON, + "2 * n * dot(n, l) - l is already unit length for unit n and l, so the lobe needs no normalization, yet this fixture vector squared to {}", + reflected.get_x().powf(2.0) + reflected.get_y().powf(2.0), + ); + let on_the_peak: Vector3D = compute_phong(&light, normal, reflected, &material); + assert!( + (on_the_peak.get_x() - 1.0).abs() < EPSILON, + "the lobe peaks at exactly 1.0 where the eye sits on reflect(-l, n) == (-0.6, 0.8, 0.0), got {}", + on_the_peak.get_x(), + ); + let seen_from_the_normal: Vector3D = compute_phong(&light, normal, normal, &material); + assert!( + (seen_from_the_normal.get_x() - 0.64).abs() < EPSILON, + "seen from the normal the angle to the reflection is 0.8, so the reflective Phong factor is 0.8^2 == 0.64, got {}", + seen_from_the_normal.get_x(), + ); + assert!( + (seen_from_the_normal.get_x() - 0.9).abs() > 1e-6, + "the reflective Phong lobe must not read as the Blinn half-vector value pow(0.9, 2) == 0.9, got {}", + seen_from_the_normal.get_x(), ); } @@ -141,7 +181,11 @@ fn lighting_setup() -> LightingUniforms { #[test] fn shade_lambert_has_no_specular_lobe() { - let uniforms: LightingUniforms = lighting_setup(); + let mut uniforms: LightingUniforms = LightingUniforms::with_eye(Vector3D::new(-6.0, 0.0, 8.0)); + uniforms.add_light(Light::new_directional( + Vector3D::new(0.6, 0.0, 0.8), + Vector3D::new(1.0, 1.0, 1.0), + )); let normal: Vector3D = Vector3D::new(0.0, 0.0, 1.0); let pos: Vector3D = Vector3D::zero(); let lambert: Material = Material::new( @@ -160,6 +204,16 @@ fn shade_lambert_has_no_specular_lobe() { Vector3D::zero(), ); let shaded_phong: Vector3D = uniforms.shade(pos, normal, &phong, &[]); + assert!( + (shaded.get_z() - 0.5).abs() < EPSILON, + "ambient 0.1 plus diffuse 0.5 * 0.8 with no specular lobe at all, got {}", + shaded.get_z(), + ); + assert!( + (shaded_phong.get_z() - 1.5).abs() < EPSILON, + "ambient 0.1 plus diffuse 0.4 plus a full specular lobe aimed at this eye, got {}", + shaded_phong.get_z(), + ); assert!( shaded_phong.get_z() > shaded.get_z() + EPSILON, "a Phong material must pick up a specular lobe the Lambert one lacks: phong={} lambert={}", @@ -379,3 +433,379 @@ fn the_collider_shape_enums_default_to_the_box_form() { ); assert_ne!(ColliderShape3D::Sphere, ColliderShape3D::Aabb, "in 3D too"); } + +#[test] +fn a_spot_lights_a_surface_on_its_beam_and_leaves_one_off_axis_dark() { + let half_angle: f64 = 0.5; + let drop: f64 = 10.0; + let lamp: Vector3D = Vector3D::new(0.0, drop, 0.0); + let axis: Vector3D = Vector3D::new(0.0, -1.0, 0.0); + let eye: Vector3D = Vector3D::new(0.0, 0.0, 30.0); + let color: Vector3D = Vector3D::new(1.0, 1.0, 1.0); + let mut uniforms: LightingUniforms = LightingUniforms::with_eye(eye); + uniforms.add_light(Light::new_spot(lamp, axis, color, 1.0, half_angle)); + let material: Material = Material::lambert(color); + let facing_up: Vector3D = Vector3D::new(0.0, 1.0, 0.0); + let on_beam: Vector3D = uniforms.shade(Vector3D::zero(), facing_up, &material, &[]); + let on_beam_expected: f64 = 0.1 + 1.0 / (1.0 + drop * drop); + assert!( + (on_beam.get_x() - on_beam_expected).abs() < 1e-12, + "10 units below a lamp on the beam has cosine 1.0 and falloff 1/(1+100) = 0.0099, so ambient 0.1 + that gives {on_beam_expected}, got {}", + on_beam.get_x(), + ); + let ninety_degrees_off_axis: Vector3D = + uniforms.shade(Vector3D::new(10.0, 0.0, 0.0), facing_up, &material, &[]); + assert!( + (ninety_degrees_off_axis.get_x() - 0.1).abs() < EPSILON, + "a surface 90 degrees off a 0.5 rad cone must fall back to ambient 0.1 even though its normal faces the lamp, got {}", + ninety_degrees_off_axis.get_x(), + ); + let slant: f64 = drop * 0.4f64.tan(); + let inside: Vector3D = + uniforms.shade(Vector3D::new(slant, 0.0, 0.0), facing_up, &material, &[]); + let true_distance: f64 = drop / 0.4f64.cos(); + let cosine: f64 = drop / true_distance; + let falloff: f64 = 1.0 / (1.0 + true_distance * true_distance); + let expected: f64 = 0.1 + cosine * falloff; + assert!( + (inside.get_x() - expected).abs() < 1e-12, + "a surface exactly 0.4 rad off axis is inside the cone, giving cosine {cosine} and falloff {falloff} over ambient 0.1, expected {expected}, got {}", + inside.get_x(), + ); +} + +#[test] +fn a_surface_exactly_on_the_spot_cone_boundary_is_lit_and_one_step_past_it_is_not() { + let half_angle: f64 = 0.5; + let drop: f64 = 10.0; + let boundary_slant: f64 = drop * half_angle.tan(); + let true_distance: f64 = drop / half_angle.cos(); + let eye: Vector3D = Vector3D::new(0.0, 0.0, 30.0); + let color: Vector3D = Vector3D::new(1.0, 1.0, 1.0); + let mut uniforms: LightingUniforms = LightingUniforms::with_eye(eye); + uniforms.add_light(Light::new_spot( + Vector3D::new(0.0, drop, 0.0), + Vector3D::new(0.0, -1.0, 0.0), + color, + 1.0, + half_angle, + )); + let material: Material = Material::lambert(color); + let surface_normal: Vector3D = Vector3D::new(boundary_slant, drop, 0.0).normalized(); + let on_boundary: Vector3D = uniforms.shade( + Vector3D::new(boundary_slant, 0.0, 0.0), + surface_normal, + &material, + &[], + ); + let surface_cosine: f64 = + (drop * drop - boundary_slant * boundary_slant) / (true_distance * true_distance); + let falloff: f64 = 1.0 / (1.0 + true_distance * true_distance); + let expected: f64 = 0.1 + surface_cosine * falloff; + assert!( + (on_boundary.get_x() - expected).abs() < 1e-12, + "the cone test is dot(light_dir, axis) <= -cos(half_angle), so the surface exactly on the boundary is lit; its normal tilts toward the lamp as much as the lamp tilts away, making the diffuse cosine {surface_cosine} with falloff {falloff} over ambient 0.1, expected {expected}, got {}", + on_boundary.get_x(), + ); + let just_past: Vector3D = uniforms.shade( + Vector3D::new(boundary_slant * 1.01, 0.0, 0.0), + surface_normal, + &material, + &[], + ); + assert!( + (just_past.get_x() - 0.1).abs() < EPSILON, + "1 percent past the cone boundary must fall back to ambient 0.1, got {}", + just_past.get_x(), + ); +} + +#[test] +fn the_visibility_callback_is_called_once_per_light_with_the_true_distance_and_unit_direction() { + let eye: Vector3D = Vector3D::new(0.0, 0.0, 10.0); + let color: Vector3D = Vector3D::new(1.0, 1.0, 1.0); + let lamp: Vector3D = Vector3D::new(0.0, 3.0, 4.0); + let mut uniforms: LightingUniforms = LightingUniforms::with_eye(eye); + uniforms.add_light(Light::new_point(lamp, color, 1.0)); + let material: Material = Material::lambert(color); + let facing_up: Vector3D = Vector3D::new(0.0, 1.0, 0.0); + let mut calls: Vec<(LightType, Vector3D, f64)> = Vec::new(); + let shaded: Vector3D = uniforms.shade_with_visibility( + Vector3D::zero(), + facing_up, + &material, + |light: &Light, dir: Vector3D, max: f64| { + calls.push((light.get_kind(), dir, max)); + 1.0 + }, + ); + assert_eq!( + calls.len(), + 1, + "one contributing light must cost exactly one callback, got {calls:?}" + ); + let recorded: (LightType, Vector3D, f64) = calls[0]; + let kind: LightType = recorded.0; + let dir: Vector3D = recorded.1; + let max_distance: f64 = recorded.2; + assert_eq!( + kind, + LightType::Point, + "the callback must receive the light itself so it can read its position" + ); + assert!( + (dir.get_y() - 0.6).abs() < EPSILON && (dir.get_z() - 0.8).abs() < EPSILON, + "the callback direction must be the unit surface-to-light vector (0, 0.6, 0.8) of the 3-4-5 triangle, got {dir:?}" + ); + assert!( + (max_distance - 5.0).abs() < EPSILON, + "max_distance must be the true surface-to-light distance 5.0 of the 3-4-5 triangle, never the 0.001 falloff floor, got {max_distance}" + ); + let expected: f64 = 0.1 + 0.6 / 26.0; + assert!( + (shaded.get_x() - expected).abs() < 1e-9, + "cosine 0.6 with falloff 1/(1+25) over ambient 0.1 gives {expected}, got {}", + shaded.get_x(), + ); +} + +#[test] +fn the_visibility_factor_scales_the_whole_contribution_and_zero_leaves_only_ambient() { + let eye: Vector3D = Vector3D::new(0.0, 0.0, 10.0); + let red_only: Vector3D = Vector3D::new(1.0, 0.0, 0.0); + let facing_camera: Vector3D = Vector3D::new(0.0, 0.0, 1.0); + let mut uniforms: LightingUniforms = LightingUniforms::with_eye(eye); + uniforms.add_light(Light::new_directional(facing_camera, red_only)); + let material: Material = Material::lambert(Vector3D::new(1.0, 1.0, 1.0)); + let half_visible: Vector3D = uniforms.shade_with_visibility( + Vector3D::zero(), + facing_camera, + &material, + |_light: &Light, _dir: Vector3D, _max: f64| 0.5, + ); + let fully_occluded: Vector3D = uniforms.shade_with_visibility( + Vector3D::zero(), + facing_camera, + &material, + |_light: &Light, _dir: Vector3D, _max: f64| 0.0, + ); + assert!( + (half_visible.get_x() - 0.6).abs() < EPSILON, + "a cosine of 1.0 at 0.5 visibility over ambient 0.1 gives 0.6, got {}", + half_visible.get_x(), + ); + assert!( + (fully_occluded.get_x() - 0.1).abs() < EPSILON, + "a fully occluded light must leave only the unshadowed ambient 0.1, got {}", + fully_occluded.get_x(), + ); +} + +#[test] +fn back_facing_zero_intensity_and_out_of_cone_lights_cost_no_callback() { + let eye: Vector3D = Vector3D::new(0.0, 0.0, 10.0); + let color: Vector3D = Vector3D::new(1.0, 1.0, 1.0); + let facing_camera: Vector3D = Vector3D::new(0.0, 0.0, 1.0); + let away_from_camera: Vector3D = Vector3D::new(0.0, 0.0, -1.0); + let calls: Rc> = Rc::new(Cell::new(0)); + let counter: Rc> = Rc::clone(&calls); + let mut count: CountingVisibility = + Box::new(move |_light: &Light, _dir: Vector3D, _max: f64| -> f64 { + counter.set(counter.get() + 1); + 1.0 + }); + let material: Material = Material::lambert(color); + let mut sun_behind_the_surface: LightingUniforms = LightingUniforms::with_eye(eye); + sun_behind_the_surface.add_light(Light::new_directional(away_from_camera, color)); + let _: Vector3D = sun_behind_the_surface.shade_with_visibility( + Vector3D::zero(), + facing_camera, + &material, + &mut count, + ); + assert_eq!( + calls.get(), + 0, + "a directional light's direction points toward the lamp, so a surface whose normal opposes it has dot(n, l) < 0 and must be rejected before any shadow ray" + ); + let mut unlit: LightingUniforms = LightingUniforms::with_eye(eye); + unlit.add_light(Light::new( + LightType::Directional, + Vector3D::zero(), + facing_camera, + color, + 0.0, + 0.0, + 0.0, + )); + let _: Vector3D = + unlit.shade_with_visibility(Vector3D::zero(), facing_camera, &material, &mut count); + assert_eq!( + calls.get(), + 0, + "a zero-intensity light must be rejected before any shadow ray" + ); + let mut spot: LightingUniforms = LightingUniforms::with_eye(eye); + spot.add_light(Light::new_spot( + Vector3D::zero(), + Vector3D::new(0.0, -1.0, 0.0), + color, + 1.0, + 0.2, + )); + let _: Vector3D = spot.shade_with_visibility( + Vector3D::new(10.0, 0.0, 0.0), + Vector3D::new(0.0, 1.0, 0.0), + &material, + &mut count, + ); + assert_eq!( + calls.get(), + 0, + "a surface outside a 0.2 rad cone must be rejected before any shadow ray" + ); + let facing: Vector3D = sun_behind_the_surface.shade_with_visibility( + Vector3D::zero(), + away_from_camera, + &material, + &mut count, + ); + assert_eq!( + calls.get(), + 1, + "the same light facing the surface must reach the callback exactly once, shaded {facing:?}" + ); +} + +#[test] +fn a_directional_light_reports_the_ray_tracer_far_bound_instead_of_a_distance() { + let eye: Vector3D = Vector3D::new(0.0, 0.0, 10.0); + let color: Vector3D = Vector3D::new(1.0, 1.0, 1.0); + let facing_camera: Vector3D = Vector3D::new(0.0, 0.0, 1.0); + let mut uniforms: LightingUniforms = LightingUniforms::with_eye(eye); + uniforms.add_light(Light::new_directional(facing_camera, color)); + let material: Material = Material::lambert(color); + let mut seen: f64 = -1.0; + let _: Vector3D = uniforms.shade_with_visibility( + Vector3D::zero(), + facing_camera, + &material, + |_light: &Light, _dir: Vector3D, max: f64| -> f64 { + seen = max; + 1.0 + }, + ); + assert!( + (seen - 1000.0).abs() < EPSILON, + "a directional light has no distance of its own, so its shadow ray runs to the 1000.0 far bound, got {seen}" + ); +} + +#[test] +fn a_point_light_dimmonotonically_as_it_recedes_and_reports_true_distances() { + let eye: Vector3D = Vector3D::new(0.0, 0.0, 10.0); + let color: Vector3D = Vector3D::new(1.0, 1.0, 1.0); + let facing_up: Vector3D = Vector3D::new(0.0, 1.0, 0.0); + let material: Material = Material::lambert(color); + let mut seen: Vec = Vec::new(); + let mut previous: f64 = f64::INFINITY; + for distance in [0.5, 1.0, 2.0, 4.0, 8.0] { + let mut uniforms: LightingUniforms = LightingUniforms::with_eye(eye); + uniforms.add_light(Light::new_point( + Vector3D::new(0.0, distance, 0.0), + color, + 1.0, + )); + let shaded: Vector3D = uniforms.shade_with_visibility( + Vector3D::zero(), + facing_up, + &material, + |_light: &Light, _dir: Vector3D, max: f64| -> f64 { + seen.push(max); + 1.0 + }, + ); + let expected: f64 = 0.1 + 1.0 / (1.0 + distance * distance); + assert!( + (shaded.get_x() - expected).abs() < 1e-9, + "face-on at d={distance} falloff is 1/(1+d^2) over ambient 0.1, expected {expected}, got {}", + shaded.get_x(), + ); + assert!( + shaded.get_x() < previous, + "a point light must dim as it recedes, and the 0.5 sample sits strictly between the 0.001 falloff floor and any real distance so a clamped max_distance would show up here, {shaded:?} is not dimmer than {previous}" + ); + previous = shaded.get_x(); + } + assert_eq!( + seen, + vec![0.5, 1.0, 2.0, 4.0, 8.0], + "every callback must see the true surface-to-light distance regardless of the falloff applied, got {seen:?}" + ); +} + +#[test] +fn a_surface_inside_the_min_distance_still_reports_the_true_distance_to_its_lamp() { + let eye: Vector3D = Vector3D::new(0.0, 0.0, 10.0); + let color: Vector3D = Vector3D::new(1.0, 1.0, 1.0); + let closer_than_the_falloff_floor: f64 = 1e-5; + let lamp: Vector3D = Vector3D::new(0.0, closer_than_the_falloff_floor, 0.0); + let mut uniforms: LightingUniforms = LightingUniforms::with_eye(eye); + uniforms.add_light(Light::new_point(lamp, color, 1.0)); + let material: Material = Material::lambert(color); + let facing_up: Vector3D = Vector3D::new(0.0, 1.0, 0.0); + let mut seen: f64 = -1.0; + let shaded: Vector3D = uniforms.shade_with_visibility( + Vector3D::zero(), + facing_up, + &material, + |_light: &Light, _dir: Vector3D, max: f64| -> f64 { + seen = max; + 1.0 + }, + ); + assert!( + shaded.get_x().is_finite(), + "a surface 1e-5 from a lamp must not shade to NaN or infinity, got {}", + shaded.get_x(), + ); + let floored_falloff: f64 = 1.0 / (1.0 + 0.001f64 * 0.001); + assert!( + (shaded.get_x() - (0.1 + floored_falloff)).abs() < 1e-9, + "the falloff distance alone is floored at 0.001, so a 1e-5 lamp distance must attenuate by 1/(1+0.001^2) == {floored_falloff} over ambient 0.1, got {}", + shaded.get_x(), + ); + assert!( + (seen - closer_than_the_falloff_floor).abs() < 1e-12, + "max_distance must stay the true 1e-5 distance, never the 0.001 falloff floor, got {seen}" + ); +} + +#[test] +fn a_surface_exactly_on_a_point_light_is_finite_and_costs_no_callback() { + let eye: Vector3D = Vector3D::new(0.0, 0.0, 10.0); + let color: Vector3D = Vector3D::new(1.0, 1.0, 1.0); + let mut uniforms: LightingUniforms = LightingUniforms::with_eye(eye); + uniforms.add_light(Light::new_point(Vector3D::zero(), color, 1.0)); + let material: Material = Material::lambert(color); + let facing_up: Vector3D = Vector3D::new(0.0, 1.0, 0.0); + let calls: Rc> = Rc::new(Cell::new(0)); + let counter: Rc> = Rc::clone(&calls); + let mut count: CountingVisibility = + Box::new(move |_light: &Light, _dir: Vector3D, _max: f64| -> f64 { + counter.set(counter.get() + 1); + 1.0 + }); + let shaded: Vector3D = + uniforms.shade_with_visibility(Vector3D::zero(), facing_up, &material, &mut count); + assert!( + shaded.get_x().is_finite(), + "a surface sitting exactly on a lamp has a zero direction vector and must not shade to NaN or infinity, got {}", + shaded.get_x(), + ); + assert_eq!( + calls.get(), + 0, + "the zero direction fails the dot(n, l) > 0 facing test, so a coincident surface is culled before any shadow ray" + ); +} diff --git a/engine/tests/mod.rs b/engine/tests/mod.rs index e83364bc..644e0b6f 100644 --- a/engine/tests/mod.rs +++ b/engine/tests/mod.rs @@ -37,6 +37,7 @@ mod physics; mod physics_world; mod pool; mod quadtree; +mod raypipeline; mod raytracing; mod remaining; mod renderer; diff --git a/engine/tests/raypipeline/fn.rs b/engine/tests/raypipeline/fn.rs new file mode 100644 index 00000000..688c6a87 --- /dev/null +++ b/engine/tests/raypipeline/fn.rs @@ -0,0 +1,809 @@ +use super::*; + +fn camera() -> Camera3D { + Camera3D::create(Vector3D::new(0.0, 0.0, 6.0), Vector3D::zero(), 64.0, 64.0) +} + +fn mixed_scene() -> RayTraceScene { + let occluders: Vec = vec![ + Occluder::sphere( + Vector3D::new(-1.2, 0.0, 0.0), + 0.9, + Material::lambert(Vector3D::new(0.8, 0.2, 0.2)), + ), + Occluder::aabb( + Vector3D::new(0.4, -0.6, -0.6), + Vector3D::new(1.8, 0.6, 0.6), + Material::phong(Vector3D::new(0.2, 0.8, 0.3), 0.6, 32.0), + ), + Occluder::triangle( + Vector3D::new(-0.2, -0.9, 1.0), + Vector3D::new(0.6, -0.9, 1.0), + Vector3D::new(0.2, 0.9, 1.0), + Material::pbr(Vector3D::new(0.2, 0.3, 0.9)), + ), + ]; + RayTraceScene::new(occluders) +} + +fn lit_scene() -> LightingUniforms { + let mut lights: LightingUniforms = LightingUniforms::with_eye(Vector3D::new(0.0, 0.0, 6.0)); + lights.set_ambient(Vector3D::new(0.1, 0.12, 0.15)); + lights.add_light(Light::new_directional( + Vector3D::new(0.4, 0.8, 1.0), + Vector3D::new(1.0, 0.95, 0.9), + )); + lights.add_light(Light::new_point( + Vector3D::new(2.0, 2.0, 2.0), + Vector3D::new(0.6, 0.7, 1.0), + 1.2, + )); + lights.add_light(Light::new_spot( + Vector3D::new(-2.0, 2.5, 1.0), + Vector3D::new(0.3, -1.0, -0.4), + Vector3D::new(1.0, 0.8, 0.5), + 2.0, + 0.6, + )); + lights +} + +#[test] +fn settings_defaults_validate() { + let settings: RayTraceSettings = RayTraceSettings::new_default(); + assert_eq!(settings.get_samples_per_axis(), 1); + assert_eq!(settings.get_max_bounces(), 4); + assert!(settings.validate().is_ok()); + assert_eq!(settings.rays_per_pixel(), 1); + assert_eq!( + RayTraceSettings::with_quality(3, 2).rays_per_pixel(), + 9, + "rays_per_pixel must be samples_per_axis squared" + ); +} + +#[test] +fn settings_validate_rejects_out_of_range() { + for samples in [0u32, 5, 1000] { + let settings: RayTraceSettings = RayTraceSettings::with_quality(samples, 1); + assert!( + settings.validate().is_err(), + "samples_per_axis {samples} must be rejected" + ); + } + for bounces in [17u32, 1000] { + let settings: RayTraceSettings = RayTraceSettings::with_quality(1, bounces); + assert!( + settings.validate().is_err(), + "max_bounces {bounces} must be rejected" + ); + } + assert!( + RayTraceSettings::with_quality(4, 16).validate().is_ok(), + "both inclusive upper bounds must be accepted" + ); + assert!( + RayTraceSettings::with_quality(1, 0).validate().is_ok(), + "zero bounces must be accepted" + ); +} + +#[test] +fn encode_gpu_empty_scene_packs_only_a_header() { + let scene: RayTraceScene = RayTraceScene::new(Vec::new()); + let packed: Vec = match scene.encode_gpu() { + Ok(data) => data, + Err(reason) => panic!("empty scene must pack: {reason}"), + }; + assert_eq!(packed.len(), 4, "an empty scene is exactly one vec4 header"); + assert_eq!(packed[0], 0.0, "node_count"); + assert_eq!(packed[1], 0.0, "primitive_count"); + assert_eq!(packed[2], 1.0, "primitive_offset is header + nodes"); +} + +#[test] +fn encode_gpu_header_shapes_and_indices() { + let scene: RayTraceScene = mixed_scene(); + let packed: Vec = match scene.encode_gpu() { + Ok(data) => data, + Err(reason) => panic!("mixed scene must pack: {reason}"), + }; + let node_count: f32 = packed[0]; + let primitive_count: f32 = packed[1]; + let primitive_offset: f32 = packed[2]; + assert_eq!(primitive_count, 3.0, "one record per occluder"); + assert_eq!(primitive_offset, 1.0 + 3.0 * node_count); + assert_eq!( + packed.len() % 4, + 0, + "the packed buffer is a whole number of vec4 slots" + ); + let total_slots: usize = packed.len() / 4; + let expected_slots: usize = 1 + (node_count as usize) * 3 + (primitive_count as usize) * 8; + assert_eq!(total_slots, expected_slots, "no padding slots are appended"); + for (record, expected_shape) in [(0usize, 0.0f32), (1, 1.0), (2, 2.0)] { + let shape_slot: usize = primitive_offset as usize + record * 8; + assert_eq!(packed[shape_slot * 4 + 3], expected_shape); + } + let original_indices: Vec = (0..3) + .map(|record: usize| { + let vertex0: usize = primitive_offset as usize + record * 8 + 2; + packed[vertex0 * 4 + 3] + }) + .collect(); + for (expected, original) in original_indices.iter().enumerate().take(3) { + assert_eq!( + *original, expected as f32, + "each record carries its original primitive index" + ); + } +} + +#[test] +fn encode_gpu_rejects_non_finite_and_overflow() { + let broken: Vec = vec![Occluder::sphere( + Vector3D::new(f64::NAN, 0.0, 0.0), + 1.0, + Material::lambert(Vector3D::zero()), + )]; + assert!( + RayTraceScene::new(broken).encode_gpu().is_err(), + "a NaN centre must be rejected rather than packed" + ); + let huge: Vec = vec![Occluder::sphere( + Vector3D::new(f64::MAX, 0.0, 0.0), + 1.0, + Material::lambert(Vector3D::zero()), + )]; + assert!( + RayTraceScene::new(huge).encode_gpu().is_err(), + "a centre beyond the f32 range must be rejected rather than truncated" + ); +} + +#[test] +fn encode_gpu_bounds_round_outward() { + let awkward: f64 = 0.1_f64; + let scene: RayTraceScene = RayTraceScene::new(vec![Occluder::aabb( + Vector3D::new(-awkward, -awkward, -awkward), + Vector3D::new(awkward, awkward, awkward), + Material::lambert(Vector3D::zero()), + )]); + let packed: Vec = match scene.encode_gpu() { + Ok(data) => data, + Err(reason) => panic!("scene must pack: {reason}"), + }; + let node_count: usize = packed[0] as usize; + let offset: usize = 1; + for node in 0..node_count { + let lower: &[f32] = &packed[(offset + node * 3) * 4..(offset + node * 3) * 4 + 3]; + let upper: &[f32] = &packed[(offset + node * 3 + 1) * 4..(offset + node * 3 + 1) * 4 + 3]; + for axis in 0..3 { + assert!( + (lower[axis] as f64) <= -awkward, + "packed lower bound {} must not exceed the source {}", + lower[axis], + -awkward + ); + assert!( + (upper[axis] as f64) >= awkward, + "packed upper bound {} must not fall below the source {}", + upper[axis], + awkward + ); + } + } +} + +#[test] +fn encode_gpu_sphere_bounds_still_enclose_the_source() { + let center: Vector3D = Vector3D::new(0.1, -0.3, 0.7); + let radius: f64 = 0.45; + let scene: RayTraceScene = RayTraceScene::new(vec![Occluder::sphere( + center, + radius, + Material::lambert(Vector3D::zero()), + )]); + let packed: Vec = match scene.encode_gpu() { + Ok(data) => data, + Err(reason) => panic!("scene must pack: {reason}"), + }; + let offset: usize = packed[2] as usize; + let cx: f64 = packed[offset * 4] as f64; + let cy: f64 = packed[offset * 4 + 1] as f64; + let cz: f64 = packed[offset * 4 + 2] as f64; + let packed_radius: f64 = packed[(offset + 1) * 4] as f64; + assert!( + cx <= center.get_x() && cy <= center.get_y() && cz <= center.get_z(), + "the packed centre must round toward -inf on every axis, got ({cx}, {cy}, {cz}) vs ({}, {}, {})", + center.get_x(), + center.get_y(), + center.get_z() + ); + let centre_drift: f64 = ((cx - center.get_x()).powi(2) + + (cy - center.get_y()).powi(2) + + (cz - center.get_z()).powi(2)) + .sqrt(); + assert!( + packed_radius as f64 >= radius + centre_drift, + "the widened radius must cover the centre's drift: packed {packed_radius}, source {radius}, drift {centre_drift}" + ); + let slack: f64 = packed_radius as f64 - radius; + assert!( + slack >= 0.0 && slack <= radius * 1e-3 + 1e-6, + "the radius widening must stay a rounding-scale correction, got {slack}" + ); + let drift: f64 = ((cx - center.get_x()).powi(2) + + (cy - center.get_y()).powi(2) + + (cz - center.get_z()).powi(2)) + .sqrt(); + assert!( + packed_radius >= radius + drift, + "the packed radius {} must cover the source radius {} plus the centre drift {}", + packed_radius, + radius, + drift + ); +} + +#[test] +fn frame_header_layout_and_light_records() { + let camera: Camera3D = camera(); + let lights: LightingUniforms = lit_scene(); + let settings: RayTraceSettings = RayTraceSettings::with_quality(2, 3); + let mut packed: Vec = Vec::new(); + if let Err(reason) = pack_raytrace_frame(&mut packed, &camera, &lights, &settings, 32, 16) { + panic!("frame must pack: {reason}"); + } + assert_eq!( + packed.len() % 4, + 0, + "the packed frame is a whole number of vec4 slots" + ); + let light_count: usize = packed[19] as usize; + assert_eq!( + packed.len() / 4, + 8 + light_count * 4, + "header plus one record per light" + ); + assert!( + light_count > 0, + "the fixture must declare at least one light" + ); + assert!((packed[0] as f64 - camera.get_position().get_x()).abs() < 1e-6); + assert!((packed[1] as f64 - camera.get_position().get_y()).abs() < 1e-6); + assert!((packed[2] as f64 - camera.get_position().get_z()).abs() < 1e-6); + assert!( + (packed[3] as f64 - (camera.get_fov() * 0.5).tan()).abs() < 1e-6, + "slot 0.w must carry tan(fov / 2), not the raw fov" + ); + assert!( + (packed[7] as f64 - (32.0 / 16.0)).abs() < 1e-6, + "slot 1.w must carry width / height aspect" + ); + assert_eq!(packed[11], 32.0, "slot 2.w carries width"); + assert_eq!(packed[15], 16.0, "slot 3.w carries height"); + assert_eq!( + packed[19], light_count as f32, + "slot 4.w carries light_count" + ); + assert_eq!( + packed[23], + settings.get_max_bounces() as f32, + "slot 5.w carries max_bounces" + ); + assert!( + (packed[24] as f64 - camera.get_near()).abs() < 1e-6, + "slot 6.x carries near" + ); + assert!( + (packed[25] as f64 - camera.get_far()).abs() < 1e-6, + "slot 6.y carries far" + ); + assert_eq!( + packed[26], + settings.get_samples_per_axis() as f32, + "slot 6.z carries samples_per_axis" + ); + for light in 0..light_count { + let base_word: usize = 32 + light * 16; + assert_eq!( + packed[base_word + 3], + light as f32, + "a light's kind code must match its LightType discriminant order" + ); + assert!( + packed[base_word + 13] == 0.0 && packed[base_word + 14] == 0.0, + "only spot_cos occupies the final light slot's y and z" + ); + } +} + +#[test] +fn frame_rejects_invalid_camera_and_dimensions() { + let lights: LightingUniforms = lit_scene(); + let settings: RayTraceSettings = RayTraceSettings::new_default(); + let mut packed: Vec = Vec::new(); + assert!( + pack_raytrace_frame(&mut packed, &camera(), &lights, &settings, 0, 16).is_err(), + "a zero width must be rejected" + ); + assert!( + pack_raytrace_frame(&mut packed, &camera(), &lights, &settings, 16, 0).is_err(), + "a zero height must be rejected" + ); + let mut degenerate: Camera3D = camera(); + degenerate.set_target(degenerate.get_position()); + assert!( + pack_raytrace_frame(&mut packed, °enerate, &lights, &settings, 16, 16).is_err(), + "a zero forward vector must be rejected" + ); + let mut parallel: Camera3D = camera(); + let forward: Vector3D = parallel.forward(); + parallel.set_up(forward); + assert!( + pack_raytrace_frame(&mut packed, ¶llel, &lights, &settings, 16, 16).is_err(), + "an up vector parallel to forward must be rejected" + ); + let mut wide: Camera3D = camera(); + wide.set_fov(4.0); + assert!( + pack_raytrace_frame(&mut packed, &wide, &lights, &settings, 16, 16).is_err(), + "an fov at or beyond PI must be rejected" + ); + let mut inverted: Camera3D = camera(); + inverted.set_near(inverted.get_far()); + assert!( + pack_raytrace_frame(&mut packed, &inverted, &lights, &settings, 16, 16).is_err(), + "a near plane at or beyond the far plane must be rejected" + ); + let mut negative: Camera3D = camera(); + negative.set_near(-1.0); + assert!( + pack_raytrace_frame(&mut packed, &negative, &lights, &settings, 16, 16).is_err(), + "a negative near plane must be rejected" + ); + let mut unbounded: Camera3D = camera(); + unbounded.set_far(f64::MAX); + assert!( + pack_raytrace_frame(&mut packed, &unbounded, &lights, &settings, 16, 16).is_err(), + "a far plane beyond the f32 range must be rejected, not written as infinity" + ); + let mut beyond: Camera3D = camera(); + beyond.set_position(Vector3D::new(f64::NAN, 0.0, 0.0)); + assert!( + pack_raytrace_frame(&mut packed, &beyond, &lights, &settings, 16, 16).is_err(), + "a non-finite eye must be rejected" + ); +} + +#[test] +fn frame_reuses_its_buffer() { + let lights: LightingUniforms = lit_scene(); + let settings: RayTraceSettings = RayTraceSettings::new_default(); + let mut packed: Vec = Vec::new(); + if let Err(reason) = pack_raytrace_frame(&mut packed, &camera(), &lights, &settings, 8, 8) { + panic!("frame must pack: {reason}"); + } + let capacity: usize = packed.capacity(); + if let Err(reason) = pack_raytrace_frame(&mut packed, &camera(), &lights, &settings, 8, 8) { + panic!("frame must pack: {reason}"); + } + assert_eq!( + packed.capacity(), + capacity, + "repacking an unchanged frame must not reallocate" + ); +} + +#[test] +fn render_rejects_invalid_dimensions_and_settings() { + let scene: RayTraceScene = mixed_scene(); + let lights: LightingUniforms = lit_scene(); + let mut out: Vec = Vec::new(); + assert!( + render_raytrace_rgba( + &scene, + &camera(), + &lights, + &RayTraceSettings::new_default(), + 0, + 8, + &mut out + ) + .is_err(), + "a zero width must be rejected" + ); + assert!( + render_raytrace_rgba( + &scene, + &camera(), + &lights, + &RayTraceSettings::with_quality(9, 1), + 8, + 8, + &mut out + ) + .is_err(), + "an invalid sample grid must be rejected before any pixel is traced" + ); +} + +#[test] +fn render_empty_scene_is_ambient_everywhere() { + let scene: RayTraceScene = RayTraceScene::new(Vec::new()); + let camera: Camera3D = camera(); + let mut lights: LightingUniforms = LightingUniforms::with_eye(camera.get_position()); + lights.set_ambient(Vector3D::new(0.2, 0.4, 0.6)); + let mut out: Vec = Vec::new(); + if let Err(reason) = render_raytrace_rgba( + &scene, + &camera, + &lights, + &RayTraceSettings::new_default(), + 4, + 3, + &mut out, + ) { + panic!("empty scene must render: {reason}"); + } + assert_eq!(out.len(), 4 * 3 * 4, "RGBA, top-down, one row per scanline"); + let expected: u8 = ((0.2f64).powf(1.0 / 2.2) * 255.0).round() as u8; + let expected_green: u8 = ((0.4f64).powf(1.0 / 2.2) * 255.0).round() as u8; + for pixel in 0..12 { + assert_eq!(out[pixel * 4], expected, "red channel of pixel {pixel}"); + assert_eq!(out[pixel * 4 + 1], expected_green); + assert_eq!(out[pixel * 4 + 3], 255, "alpha is always opaque"); + } +} + +#[test] +fn render_reuses_its_output_buffer() { + let scene: RayTraceScene = mixed_scene(); + let lights: LightingUniforms = lit_scene(); + let camera: Camera3D = camera(); + let settings: RayTraceSettings = RayTraceSettings::new_default(); + let mut out: Vec = Vec::new(); + for _ in 0..2 { + if let Err(reason) = + render_raytrace_rgba(&scene, &camera, &lights, &settings, 6, 5, &mut out) + { + panic!("frame must render: {reason}"); + } + } + assert_eq!(out.len(), 6 * 5 * 4); + let capacity: usize = out.capacity(); + if let Err(reason) = render_raytrace_rgba(&scene, &camera, &lights, &settings, 6, 5, &mut out) { + panic!("frame must render: {reason}"); + } + assert_eq!( + out.capacity(), + capacity, + "a steady-state frame must not reallocate the output buffer" + ); +} + +#[test] +fn render_is_deterministic_across_repeated_frames() { + let scene: RayTraceScene = mixed_scene(); + let lights: LightingUniforms = lit_scene(); + let camera: Camera3D = camera(); + let settings: RayTraceSettings = RayTraceSettings::with_quality(2, 2); + let mut first: Vec = Vec::new(); + let mut second: Vec = Vec::new(); + if let Err(reason) = render_raytrace_rgba(&scene, &camera, &lights, &settings, 8, 6, &mut first) + { + panic!("frame must render: {reason}"); + } + if let Err(reason) = + render_raytrace_rgba(&scene, &camera, &lights, &settings, 8, 6, &mut second) + { + panic!("frame must render: {reason}"); + } + assert_eq!(first, second, "the same inputs must produce the same frame"); +} + +#[test] +fn render_produces_distinct_pixels_for_a_mixed_scene() { + let scene: RayTraceScene = mixed_scene(); + let lights: LightingUniforms = lit_scene(); + let mut out: Vec = Vec::new(); + if let Err(reason) = render_raytrace_rgba( + &scene, + &camera(), + &lights, + &RayTraceSettings::new_default(), + 24, + 18, + &mut out, + ) { + panic!("frame must render: {reason}"); + } + let mut distinct: HashSet<(u8, u8, u8)> = HashSet::new(); + for pixel in 0..24 * 18 { + distinct.insert((out[pixel * 4], out[pixel * 4 + 1], out[pixel * 4 + 2])); + } + assert!( + distinct.len() > 4, + "a mixed sphere/box/triangle scene must not render flat, got {} distinct colours", + distinct.len() + ); + let lit: usize = out + .as_chunks::<4>() + .0 + .iter() + .filter(|pixel: &&[u8; 4]| { + u32::from(pixel[0]) + u32::from(pixel[1]) + u32::from(pixel[2]) > 60 + }) + .count(); + assert!( + lit > 4, + "geometry must be visibly lit, got {lit} lit pixels" + ); +} + +#[test] +fn render_matches_per_pixel_scene_trace() { + let scene: RayTraceScene = mixed_scene(); + let camera: Camera3D = camera(); + let lights: LightingUniforms = lit_scene(); + let settings: RayTraceSettings = RayTraceSettings::new_default(); + let width: u32 = 16; + let height: u32 = 12; + let mut out: Vec = Vec::new(); + if let Err(reason) = + render_raytrace_rgba(&scene, &camera, &lights, &settings, width, height, &mut out) + { + panic!("frame must render: {reason}"); + } + let mut packed: Vec = Vec::new(); + if let Err(reason) = + pack_raytrace_frame(&mut packed, &camera, &lights, &settings, width, height) + { + panic!("frame must pack: {reason}"); + } + let eye: &[f32] = &packed[0..4]; + let projection: &[f32] = &packed[4..8]; + let right: &[f32] = &packed[8..12]; + let up: &[f32] = &packed[12..16]; + let tan_half: f64 = eye[3] as f64; + let aspect: f64 = projection[3] as f64; + let width_f64: f64 = right[3] as f64; + let height_f64: f64 = up[3] as f64; + let eye_vector: Vector3D = Vector3D::new(eye[0] as f64, eye[1] as f64, eye[2] as f64); + let forward: Vector3D = Vector3D::new( + projection[0] as f64, + projection[1] as f64, + projection[2] as f64, + ); + let right_vector: Vector3D = Vector3D::new(right[0] as f64, right[1] as f64, right[2] as f64); + let up_vector: Vector3D = Vector3D::new(up[0] as f64, up[1] as f64, up[2] as f64); + for y in 0..height { + for x in 0..width { + let ndc_x: f64 = ((x as f64 + 0.5) / width_f64) * 2.0 - 1.0; + let ndc_y: f64 = 1.0 - ((y as f64 + 0.5) / height_f64) * 2.0; + let direction: Vector3D = (forward + + right_vector.scaled(ndc_x * aspect * tan_half) + + up_vector.scaled(ndc_y * tan_half)) + .normalized(); + let ray: Ray = + Ray::new(eye_vector, direction).with_range(camera.get_near(), camera.get_far()); + let linear: Vector3D = scene.trace_with_bounces(ray, &lights, 4); + let expected: (u8, u8, u8) = ( + ((linear.get_x().clamp(0.0, 1.0)).powf(1.0 / 2.2) * 255.0).round() as u8, + ((linear.get_y().clamp(0.0, 1.0)).powf(1.0 / 2.2) * 255.0).round() as u8, + ((linear.get_z().clamp(0.0, 1.0)).powf(1.0 / 2.2) * 255.0).round() as u8, + ); + let offset: usize = ((y as usize) * (width as usize) + x as usize) * 4; + let actual: (u8, u8, u8) = (out[offset], out[offset + 1], out[offset + 2]); + assert_eq!( + actual, expected, + "pixel ({x}, {y}) must equal the scene trace it delegates to" + ); + } + } +} + +#[test] +fn supersampling_refines_the_image() { + let scene: RayTraceScene = mixed_scene(); + let lights: LightingUniforms = lit_scene(); + let camera: Camera3D = camera(); + let mut coarse: Vec = Vec::new(); + let mut fine: Vec = Vec::new(); + if let Err(reason) = render_raytrace_rgba( + &scene, + &camera, + &lights, + &RayTraceSettings::with_quality(1, 2), + 20, + 16, + &mut coarse, + ) { + panic!("frame must render: {reason}"); + } + if let Err(reason) = render_raytrace_rgba( + &scene, + &camera, + &lights, + &RayTraceSettings::with_quality(4, 2), + 20, + 16, + &mut fine, + ) { + panic!("frame must render: {reason}"); + } + let mut changed: usize = 0; + for pixel in 0..coarse.len() / 4 { + if coarse[pixel * 4] != fine[pixel * 4] { + changed += 1; + } + } + assert!( + changed > 5, + "a 4x sample grid must refine edges, changed only {changed} pixels" + ); +} + +#[test] +fn zero_bounces_still_shades_the_primary_hit() { + let scene: RayTraceScene = mixed_scene(); + let camera: Camera3D = camera(); + let mut lights: LightingUniforms = LightingUniforms::with_eye(camera.get_position()); + lights.set_ambient(Vector3D::zero()); + let emissive: RayTraceScene = RayTraceScene::new(vec![Occluder::sphere( + Vector3D::zero(), + 1.2, + Material::emissive(Vector3D::new(1.0, 0.5, 0.25)), + )]); + let mut out: Vec = Vec::new(); + if let Err(reason) = render_raytrace_rgba( + &emissive, + &camera, + &lights, + &RayTraceSettings::with_quality(1, 0), + 16, + 16, + &mut out, + ) { + panic!("frame must render: {reason}"); + } + let center: usize = (8 * 16 + 8) * 4; + assert!( + out[center] > 200 && out[center + 1] > 150 && out[center + 3] == 255, + "zero bounces still shades the primary hit, got {:?}", + (out[center], out[center + 1], out[center + 2]) + ); + let mut lit_scene_frame: Vec = Vec::new(); + if let Err(reason) = render_raytrace_rgba( + &scene, + &camera, + &lights, + &RayTraceSettings::with_quality(1, 0), + 16, + 16, + &mut lit_scene_frame, + ) { + panic!("frame must render: {reason}"); + } + assert!( + lit_scene_frame.iter().any(|byte: &u8| *byte > 0), + "a mixed scene must not render black at zero bounces" + ); +} + +#[test] +fn shader_sources_are_data_driven() { + for source in [raytrace_glsl_fragment(), raytrace_wgsl()] { + for banned in [ + "GROUND_MIN", + "MIRROR_CENTER", + "EMISSIVE_CENTER", + "SUN_RADIUS", + "SHADE_EYE", + ] { + assert!( + !source.contains(banned), + "shader must not bake scene geometry: found {banned}" + ); + } + assert!( + source.contains("samples"), + "the sample grid must arrive through the frame data" + ); + assert!( + source.contains("max_bounces"), + "the bounce limit must arrive through the frame data" + ); + } + assert!(raytrace_glsl_vertex().contains("gl_VertexID")); + assert!(raytrace_glsl_fragment().contains("texelFetch")); + assert!(raytrace_wgsl().contains("@fragment")); + assert!(raytrace_wgsl().contains("@vertex")); +} + +#[test] +fn shader_sources_apply_the_camera_fov_and_phong_reflection() { + for source in [raytrace_glsl_fragment(), raytrace_wgsl()] { + assert!( + source.contains("tan_half"), + "the camera ray must multiply by tan(fov / 2)" + ); + assert!( + source.contains("normal * (2.0 * dot(normal, light_dir)) - light_dir"), + "the Phong lobe must compute reflect(-L, N) = 2*dot(N,L)*N - L" + ); + assert!( + !source.contains("light_dir - normal * (2.0 * dot"), + "reflect(-L, N) must not be spelled with the operands swapped" + ); + assert!( + !source.contains("2.0 * dot(light_dir, normal)"), + "the dot must be taken as dot(N, L), not dot(L, N)" + ); + assert!( + !source.contains("normalize(light_dir + view_dir"), + "the Blinn half-vector form must not appear in the Phong lobe" + ); + assert!( + !source.contains("normalize(light_dir + view)"), + "the Blinn half-vector form must not appear in the Phong lobe" + ); + assert!( + source.contains("shadow_distance = distance"), + "a point or spot shadow ray must reach the actual lamp distance" + ); + } +} + +#[test] +fn shader_row_order_matches_the_cpu_reference() { + let glsl: &str = raytrace_glsl_fragment(); + let wgsl: &str = raytrace_wgsl(); + let height: f64 = 64.0; + let ndc_y_top_down: f64 = 1.0 - (8.0 / height) * 2.0; + let ndc_y_bottom_up: f64 = (8.0 / height) * 2.0 - 1.0; + assert!( + (ndc_y_top_down - 0.75).abs() < 1e-12, + "the CPU reference maps the top row to +1" + ); + assert!( + (ndc_y_bottom_up + 0.75).abs() < 1e-12, + "gl_FragCoord is bottom-up, so row 8 of 64 maps to -0.75" + ); + assert!( + (ndc_y_top_down - ndc_y_bottom_up).abs() > 1.0, + "the two row conventions must disagree in sign, or the flip is untestable" + ); + assert!( + glsl.contains("(py / resolution.y) * 2.0 - 1.0"), + "the GLSL fragment shader must map its bottom-up gl_FragCoord onto the same top-down ndc_y the CPU reference uses" + ); + assert!( + !glsl.contains("1.0 - (py / resolution.y) * 2.0"), + "negating the bottom-up gl_FragCoord flips the image vertically" + ); + assert!( + wgsl.contains("1.0 - (py / resolution.y) * 2.0"), + "the WGSL fragment position is already top-down and must not be inverted again" + ); +} + +#[test] +fn shader_sources_share_the_packed_layout() { + for source in [raytrace_glsl_fragment(), raytrace_wgsl()] { + assert!(source.contains("SCENE_SLOTS")); + assert!(source.contains("NODE_SLOTS")); + assert!(source.contains("PRIMITIVE_SLOTS")); + assert!(source.contains("FRAME_HEADER_SLOTS")); + assert!(source.contains("LIGHT_SLOTS")); + assert!( + source.contains("surface_offset") || source.contains("SURFACE_OFFSET"), + "the shadow ray must leave the shaded surface along its normal" + ); + assert!( + source.contains("arrayLength") || source.contains("textureSize"), + "buffer and texture reads must be bounds-aware" + ); + } +} diff --git a/engine/tests/raypipeline/mod.rs b/engine/tests/raypipeline/mod.rs new file mode 100644 index 00000000..e41822a8 --- /dev/null +++ b/engine/tests/raypipeline/mod.rs @@ -0,0 +1,3 @@ +mod r#fn; + +use super::*; diff --git a/engine/tests/raytracing/bvh.rs b/engine/tests/raytracing/bvh.rs new file mode 100644 index 00000000..91228ccd --- /dev/null +++ b/engine/tests/raytracing/bvh.rs @@ -0,0 +1,928 @@ +use super::*; + +const EPSILON: f64 = 1e-9; + +const SHADOW_SELF_OFFSET: f64 = 0.001; + +fn lcg(state: &mut u64) -> f64 { + *state = state + .wrapping_mul(6364136223846793005) + .wrapping_add(1442695040888963407); + ((*state >> 33) as f64) / 2147483648.0 - 1.0 +} + +fn occluder_bounds(occluder: &Occluder) -> (Vector3D, Vector3D) { + match occluder.get_kind() { + OccluderKind::Aabb => ( + Vector3D::new( + occluder + .get_center() + .get_x() + .min(occluder.get_extent().get_x()), + occluder + .get_center() + .get_y() + .min(occluder.get_extent().get_y()), + occluder + .get_center() + .get_z() + .min(occluder.get_extent().get_z()), + ), + Vector3D::new( + occluder + .get_center() + .get_x() + .max(occluder.get_extent().get_x()), + occluder + .get_center() + .get_y() + .max(occluder.get_extent().get_y()), + occluder + .get_center() + .get_z() + .max(occluder.get_extent().get_z()), + ), + ), + OccluderKind::Sphere => { + let r: f64 = occluder.get_extent().get_x().abs(); + let offset: Vector3D = Vector3D::new(r, r, r); + ( + occluder.get_center() - offset, + occluder.get_center() + offset, + ) + } + OccluderKind::Triangle => { + let [v0, v1, v2]: [Vector3D; 3] = occluder.get_vertices(); + let lo: Vector3D = Vector3D::new( + v0.get_x().min(v1.get_x()).min(v2.get_x()), + v0.get_y().min(v1.get_y()).min(v2.get_y()), + v0.get_z().min(v1.get_z()).min(v2.get_z()), + ); + let hi: Vector3D = Vector3D::new( + v0.get_x().max(v1.get_x()).max(v2.get_x()), + v0.get_y().max(v1.get_y()).max(v2.get_y()), + v0.get_z().max(v1.get_z()).max(v2.get_z()), + ); + (lo, hi) + } + } +} + +fn axis_value(value: Vector3D, axis: usize) -> f64 { + let parts: [f64; 3] = [value.get_x(), value.get_y(), value.get_z()]; + parts[axis] +} + +fn brute_sphere( + origin: Vector3D, + dir: Vector3D, + center: Vector3D, + radius: f64, + t_min: f64, + t_max: f64, +) -> Option { + if !radius.is_finite() || radius < 0.0 { + return None; + } + let a: f64 = dir.dot(dir); + if !a.is_finite() || a <= 0.0 { + return None; + } + let oc: Vector3D = origin - center; + let half_b: f64 = oc.dot(dir); + let c: f64 = oc.dot(oc) - radius * radius; + let discriminant: f64 = half_b * half_b - a * c; + if !discriminant.is_finite() || discriminant < 0.0 { + return None; + } + let root: f64 = discriminant.sqrt(); + let near: f64 = (-half_b - root) / a; + let far: f64 = (-half_b + root) / a; + if near >= t_min && near <= t_max { + return Some(near); + } + if far >= t_min && far <= t_max { + return Some(far); + } + None +} + +fn brute_aabb( + origin: Vector3D, + dir: Vector3D, + lower: Vector3D, + upper: Vector3D, + t_min: f64, + t_max: f64, +) -> Option { + let lo: Vector3D = Vector3D::new( + lower.get_x().min(upper.get_x()), + lower.get_y().min(upper.get_y()), + lower.get_z().min(upper.get_z()), + ); + let hi: Vector3D = Vector3D::new( + lower.get_x().max(upper.get_x()), + lower.get_y().max(upper.get_y()), + lower.get_z().max(upper.get_z()), + ); + let mut t_enter: f64 = f64::NEG_INFINITY; + let mut t_exit: f64 = f64::INFINITY; + for axis in 0..3 { + let d: f64 = axis_value(dir, axis); + let o: f64 = axis_value(origin, axis); + let slab_lo: f64 = axis_value(lo, axis); + let slab_hi: f64 = axis_value(hi, axis); + if d.abs() < 1e-8 { + if o < slab_lo || o > slab_hi { + return None; + } + continue; + } + let inverse: f64 = 1.0 / d; + let near: f64 = (slab_lo - o) * inverse; + let far: f64 = (slab_hi - o) * inverse; + t_enter = t_enter.max(near.min(far)); + t_exit = t_exit.min(near.max(far)); + } + if t_enter > t_exit { + return None; + } + let inside: bool = t_enter <= t_min; + let t: f64 = if inside { t_exit } else { t_enter }; + if t < t_min || t > t_max { + return None; + } + Some(t) +} + +fn brute_hit(ray: &Ray, occluders: &[Occluder]) -> Option<(usize, f64)> { + let origin: Vector3D = ray.get_origin(); + let dir: Vector3D = ray.get_direction(); + let t_min: f64 = ray.get_t_min(); + let t_max: f64 = ray.get_t_max(); + if !t_min.is_finite() || !t_max.is_finite() || t_min > t_max { + return None; + } + let squared_length: f64 = dir.dot(dir); + if !squared_length.is_finite() || squared_length <= 0.0 { + return None; + } + let mut best: Option<(usize, f64)> = None; + for (index, occluder) in occluders.iter().enumerate() { + let candidate: Option = match occluder.get_kind() { + OccluderKind::Sphere => brute_sphere( + origin, + dir, + occluder.get_center(), + occluder.get_extent().get_x(), + t_min, + t_max, + ), + OccluderKind::Aabb => { + let (lower, upper): (Vector3D, Vector3D) = occluder_bounds(occluder); + brute_aabb(origin, dir, lower, upper, t_min, t_max) + } + OccluderKind::Triangle => { + let vertices: [Vector3D; 3] = occluder.get_vertices(); + match intersect_triangle(origin, dir, vertices[0], vertices[1], vertices[2]) { + Some((t, _)) if t >= t_min && t <= t_max => Some(t), + _ => None, + } + } + }; + if let Some(t) = candidate { + let wins: bool = match best { + None => true, + Some((best_index, best_t)) => t < best_t || (t == best_t && index < best_index), + }; + if wins { + best = Some((index, t)); + } + } + } + best +} + +fn brute_occluded(ray: &Ray, occluders: &[Occluder]) -> bool { + brute_hit(ray, occluders).is_some() +} + +fn mixed_scene(count: usize, seed: u64) -> Vec { + let mut state: u64 = seed; + let mut occluders: Vec = Vec::new(); + for index in 0..count { + let center: Vector3D = Vector3D::new( + lcg(&mut state) * 6.0, + lcg(&mut state) * 4.0, + lcg(&mut state) * 6.0, + ); + let material: Material = Material::lambert(Vector3D::new(0.5, 0.5, 0.5)); + match index % 3 { + 0 => occluders.push(Occluder::sphere( + center, + 0.2 + lcg(&mut state).abs(), + material, + )), + 1 => occluders.push(Occluder::aabb( + center, + center + Vector3D::new(0.6, 0.4, 0.5), + material, + )), + _ => occluders.push(Occluder::triangle( + center, + center + Vector3D::new(0.7, 0.0, 0.1), + center + Vector3D::new(0.0, 0.5, 0.8), + material, + )), + } + } + occluders +} + +#[test] +fn bvh_closest_hit_matches_a_brute_force_scan_over_a_mixed_scene() { + let occluders: Vec = mixed_scene(96, 0x5eed_1234); + let scene: RayTraceScene = RayTraceScene::new(occluders.clone()); + let mut state: u64 = 0xabcd_9876; + let mut compared: u32 = 0; + let mut hits: u32 = 0; + for _ in 0..3000 { + let raw: Vector3D = Vector3D::new(lcg(&mut state), lcg(&mut state), lcg(&mut state)); + let magnitude: f64 = raw.magnitude(); + if !magnitude.is_finite() || magnitude <= 1e-6 { + continue; + } + let origin: Vector3D = Vector3D::new( + lcg(&mut state) * 5.0, + lcg(&mut state) * 3.0 + 1.0, + lcg(&mut state) * 5.0, + ); + let ray: Ray = Ray::new(origin, raw.scaled(1.0 / magnitude)); + let expected: Option<(usize, f64)> = brute_hit(&ray, &occluders); + let actual: Option = scene.closest_hit(&ray); + compared += 1; + if expected.is_some() { + hits += 1; + } + match (expected, actual) { + (None, None) => {} + (Some((_expected_index, expected_t)), Some(hit)) => { + assert!( + (hit.get_t() - expected_t).abs() < 1e-9, + "the BVH reported t={} but a brute-force scan returns {expected_t}", + hit.get_t(), + ); + let expected_position: Vector3D = + ray.get_origin() + ray.get_direction().scaled(expected_t); + assert!( + (hit.get_position().get_x() - expected_position.get_x()).abs() < 1e-9 + && (hit.get_position().get_y() - expected_position.get_y()).abs() < 1e-9 + && (hit.get_position().get_z() - expected_position.get_z()).abs() < 1e-9, + "the reported position must sit on the ray at the brute-force distance", + ); + } + (expected, actual) => { + panic!("the BVH returned {actual:?} but a brute-force scan returns {expected:?}") + } + } + } + assert!(compared >= 2900, "too few rays took part, got {compared}"); + assert!( + hits > 300, + "the sweep must genuinely strike geometry, got {hits} hits", + ); +} + +#[test] +fn bvh_any_hit_matches_a_brute_force_scan_over_a_mixed_scene() { + let occluders: Vec = mixed_scene(96, 0x1234_5678); + let scene: RayTraceScene = RayTraceScene::new(occluders.clone()); + let mut state: u64 = 0x0fed_cba9; + let mut occluded_count: u32 = 0; + let mut compared: u32 = 0; + for _ in 0..3000 { + let raw: Vector3D = Vector3D::new(lcg(&mut state), lcg(&mut state), lcg(&mut state)); + let magnitude: f64 = raw.magnitude(); + if !magnitude.is_finite() || magnitude <= 1e-6 { + continue; + } + let origin: Vector3D = Vector3D::new( + lcg(&mut state) * 5.0, + lcg(&mut state) * 3.0 + 1.0, + lcg(&mut state) * 5.0, + ); + let ray: Ray = Ray::new(origin, raw.scaled(1.0 / magnitude)); + let expected: bool = brute_occluded(&ray, &occluders); + let actual: bool = scene.occluded(&ray); + compared += 1; + if expected { + occluded_count += 1; + } + assert_eq!( + actual, + expected, + "any-hit disagreement for a ray from ({}, {}, {}) along ({}, {}, {})", + origin.get_x(), + origin.get_y(), + origin.get_z(), + ray.get_direction().get_x(), + ray.get_direction().get_y(), + ray.get_direction().get_z(), + ); + } + assert!(compared >= 2900, "too few rays took part, got {compared}"); + assert!( + occluded_count > 300, + "the sweep must genuinely hit geometry, got {occluded_count} blocked rays", + ); +} + +#[test] +fn any_hit_agrees_with_closest_hit_for_every_sampled_ray() { + let occluders: Vec = mixed_scene(64, 0xfeed_1111); + let scene: RayTraceScene = RayTraceScene::new(occluders); + let mut state: u64 = 0x2222_3333; + for _ in 0..1500 { + let raw: Vector3D = Vector3D::new(lcg(&mut state), lcg(&mut state), lcg(&mut state)); + let magnitude: f64 = raw.magnitude(); + if !magnitude.is_finite() || magnitude <= 1e-6 { + continue; + } + let ray: Ray = Ray::new(Vector3D::new(0.0, 1.5, 0.0), raw.scaled(1.0 / magnitude)); + assert_eq!( + scene.occluded(&ray), + scene.closest_hit(&ray).is_some(), + "the two query kinds must agree on whether the range contains any hit", + ); + } +} + +#[test] +fn closest_hit_tie_breaks_on_the_lowest_original_primitive_index() { + let ray: Ray = Ray::new(Vector3D::new(0.0, 0.0, 0.0), Vector3D::new(0.0, 0.0, 1.0)); + let mut ordered: Vec = Vec::new(); + let mut flipped: Vec = Vec::new(); + for shade in [0.1f64, 0.2, 0.3, 0.4, 0.5] { + let material: Material = Material::emissive(Vector3D::new(shade, shade, shade)); + ordered.push(Occluder::sphere( + Vector3D::new(0.0, 0.0, 2.0), + 1.0, + material.clone(), + )); + flipped.push(Occluder::sphere( + Vector3D::new(0.0, 0.0, 2.0), + 1.0, + material, + )); + } + flipped.reverse(); + let forward: RayTraceScene = RayTraceScene::new(ordered.clone()); + let backward: RayTraceScene = RayTraceScene::new(flipped.clone()); + let forward_hit: Hit = forward.closest_hit(&ray).expect("all five coincide"); + let backward_hit: Hit = backward.closest_hit(&ray).expect("all five coincide"); + assert!( + (forward_hit.get_t() - 1.0).abs() < EPSILON, + "five coincident spheres are all entered at t = 1.0, got {}", + forward_hit.get_t(), + ); + assert!( + (backward_hit.get_t() - 1.0).abs() < EPSILON, + "reversing the input order must not move the reported distance, got {}", + backward_hit.get_t(), + ); + let reference: Option<(usize, f64)> = brute_hit(&ray, &ordered); + let (winner_index, _winner_t): (usize, f64) = reference.expect("the brute force agrees"); + assert_eq!( + winner_index, 0, + "the brute-force tie rule also picks the lowest original index", + ); + let mirrored_reference: Option<(usize, f64)> = brute_hit(&ray, &flipped); + assert_eq!( + mirrored_reference.map(|(_index, _t): (usize, f64)| ()), + reference.map(|(_index, _t): (usize, f64)| ()), + "both orders resolve a hit, so the tie never erases geometry", + ); + assert!( + forward.occluded(&ray) && backward.occluded(&ray), + "the any-hit query must find the coincident surface under either order", + ); +} + +#[test] +fn an_inside_box_reports_its_exit_rather_than_a_negative_entry() { + let box_occluder: Occluder = Occluder::aabb( + Vector3D::new(-1.0, -1.0, -1.0), + Vector3D::new(1.0, 1.0, 1.0), + Material::lambert(Vector3D::new(0.5, 0.5, 0.5)), + ); + let scene: RayTraceScene = RayTraceScene::new(vec![box_occluder]); + let inside: Ray = Ray::new(Vector3D::new(0.0, 0.0, 0.0), Vector3D::new(1.0, 0.0, 0.0)); + let hit: Hit = scene.closest_hit(&inside).expect("the origin is inside"); + assert!( + (hit.get_t() - 1.0).abs() < EPSILON, + "a ray starting inside must leave through the +x face at t = 1.0, got {}", + hit.get_t(), + ); + assert!( + (hit.get_normal().get_x() - 1.0).abs() < EPSILON, + "leaving through the +x face reports that face's outward normal (1, 0, 0), got ({}, {}, {})", + hit.get_normal().get_x(), + hit.get_normal().get_y(), + hit.get_normal().get_z(), + ); + let outside: Ray = Ray::new(Vector3D::new(-5.0, 0.0, 0.0), Vector3D::new(1.0, 0.0, 0.0)); + let entry: Hit = scene.closest_hit(&outside).expect("the ray enters the box"); + assert!( + (entry.get_t() - 4.0).abs() < EPSILON, + "entering from x = -5 must report t = 4.0, got {}", + entry.get_t(), + ); + assert!( + (entry.get_normal().get_x() + 1.0).abs() < EPSILON, + "entering through the -x face reports that face's outward normal (-1, 0, 0), got ({}, {}, {})", + entry.get_normal().get_x(), + entry.get_normal().get_y(), + entry.get_normal().get_z(), + ); +} + +#[test] +fn an_inside_sphere_reports_its_exit() { + let material: Material = Material::lambert(Vector3D::new(0.5, 0.5, 0.5)); + let sphere: Occluder = Occluder::sphere(Vector3D::new(0.0, 0.0, 0.0), 2.0, material); + let scene: RayTraceScene = RayTraceScene::new(vec![sphere]); + let inside: Ray = Ray::new(Vector3D::new(0.0, 0.0, 0.0), Vector3D::new(0.0, 1.0, 0.0)); + let hit: Hit = scene.closest_hit(&inside).expect("the origin is inside"); + assert!( + (hit.get_t() - 2.0).abs() < EPSILON, + "a ray starting inside must exit at t = 2.0, got {}", + hit.get_t(), + ); + assert!( + (hit.get_normal().get_y() - 1.0).abs() < EPSILON, + "the exit point is the top of the sphere, so the outward normal is (0, 1, 0), got ({}, {}, {})", + hit.get_normal().get_x(), + hit.get_normal().get_y(), + hit.get_normal().get_z(), + ); +} + +#[test] +fn a_non_unit_direction_reports_a_true_distance() { + let material: Material = Material::lambert(Vector3D::new(0.5, 0.5, 0.5)); + let sphere: Occluder = Occluder::sphere(Vector3D::new(0.0, 0.0, 2.0), 1.0, material); + let scene: RayTraceScene = RayTraceScene::new(vec![sphere]); + let scaled: Ray = Ray::new(Vector3D::new(0.0, 0.0, 0.0), Vector3D::new(0.0, 0.0, 4.0)); + let hit: Hit = scene + .closest_hit(&scaled) + .expect("a four-times-long direction"); + assert!( + (hit.get_t() - 0.25).abs() < EPSILON, + "the direction has length 4 and the surface is one unit away, so the ray parameter is 0.25, got {}", + hit.get_t(), + ); + assert!( + (hit.get_position().get_z() - 1.0).abs() < EPSILON, + "Ray::at(0.25) along (0, 0, 4) is the world point (0, 0, 1), got ({}, {}, {})", + hit.get_position().get_x(), + hit.get_position().get_y(), + hit.get_position().get_z(), + ); +} + +#[test] +fn a_zero_direction_never_invents_a_hit() { + let material: Material = Material::lambert(Vector3D::new(0.5, 0.5, 0.5)); + let sphere: Occluder = Occluder::sphere(Vector3D::new(0.0, 0.0, 0.0), 2.0, material.clone()); + let box_occluder: Occluder = Occluder::aabb( + Vector3D::new(-1.0, -1.0, -1.0), + Vector3D::new(1.0, 1.0, 1.0), + material.clone(), + ); + let triangle: Occluder = Occluder::triangle( + Vector3D::new(-5.0, 0.0, -5.0), + Vector3D::new(5.0, 0.0, -5.0), + Vector3D::new(0.0, 0.0, 5.0), + material, + ); + let scene: RayTraceScene = RayTraceScene::new(vec![sphere, box_occluder, triangle]); + let degenerate: Ray = Ray::new(Vector3D::new(0.0, 0.0, 0.0), Vector3D::zero()); + assert!( + scene.closest_hit(°enerate).is_none(), + "a ray with no direction cannot reach any surface, even from inside one", + ); + assert!( + !scene.occluded(°enerate), + "the same ray must not be reported as blocked", + ); +} + +#[test] +fn an_axis_aligned_ray_resolves_a_parallel_slab_explicitly() { + let box_occluder: Occluder = Occluder::aabb( + Vector3D::new(-1.0, -1.0, -1.0), + Vector3D::new(1.0, 1.0, 1.0), + Material::lambert(Vector3D::new(0.5, 0.5, 0.5)), + ); + let scene: RayTraceScene = RayTraceScene::new(vec![box_occluder]); + let along_face: Ray = Ray::new(Vector3D::new(-3.0, 1.0, 0.0), Vector3D::new(1.0, 0.0, 0.0)); + let hit: Hit = scene + .closest_hit(&along_face) + .expect("the ray skims the top face"); + assert!( + (hit.get_t() - 2.0).abs() < EPSILON, + "entering through x = -1 from x = -3 must report t = 2.0, got {}", + hit.get_t(), + ); + let above: Ray = Ray::new(Vector3D::new(-3.0, 2.0, 0.0), Vector3D::new(1.0, 0.0, 0.0)); + assert!( + scene.closest_hit(&above).is_none(), + "the same direction above the box is parallel to every slab it fails, so it must miss", + ); + assert!( + !scene.occluded(&above), + "and the any-hit query must agree that nothing blocks it", + ); +} + +#[test] +fn a_with_range_ray_respects_its_clipped_bounds() { + let material: Material = Material::lambert(Vector3D::new(0.5, 0.5, 0.5)); + let sphere: Occluder = Occluder::sphere(Vector3D::new(0.0, 0.0, 5.0), 1.0, material); + let scene: RayTraceScene = RayTraceScene::new(vec![sphere]); + let base: Ray = Ray::new(Vector3D::new(0.0, 0.0, 0.0), Vector3D::new(0.0, 0.0, 1.0)); + assert!( + scene.closest_hit(&base).is_some(), + "the default range reaches the sphere at t = 4", + ); + let clipped: Ray = base.with_range(0.001, 2.0); + assert!( + scene.closest_hit(&clipped).is_none(), + "clipping t_max below the hit distance must remove it", + ); + assert!( + !scene.occluded(&clipped), + "the any-hit query honours the same clipped range", + ); + let wide: Ray = base.with_range(0.001, 100.0); + let hit: Hit = scene + .closest_hit(&wide) + .expect("the widened range still reaches"); + assert!( + (hit.get_t() - 4.0).abs() < EPSILON, + "widening the range must not move the hit, got {}", + hit.get_t(), + ); + assert_eq!( + wide.get_depth(), + base.get_depth(), + "with_range must preserve the bounce budget", + ); +} + +#[test] +fn an_inverted_or_non_finite_range_is_untraceable() { + let material: Material = Material::lambert(Vector3D::new(0.5, 0.5, 0.5)); + let sphere: Occluder = Occluder::sphere(Vector3D::new(0.0, 0.0, 2.0), 1.0, material); + let scene: RayTraceScene = RayTraceScene::new(vec![sphere]); + let base: Ray = Ray::new(Vector3D::new(0.0, 0.0, 0.0), Vector3D::new(0.0, 0.0, 1.0)); + let inverted: Ray = base.with_range(10.0, 1.0); + assert!( + scene.closest_hit(&inverted).is_none(), + "an inverted range describes no points on the ray, so it must miss", + ); + assert!( + !scene.occluded(&inverted), + "and the any-hit query must not invent a blocker", + ); + let not_a_number: Ray = base.with_range(f64::NAN, 10.0); + assert!( + scene.closest_hit(¬_a_number).is_none(), + "a NaN bound cannot compare, so it must be rejected rather than pass", + ); + let infinite: Ray = base.with_range(0.001, f64::INFINITY); + assert!( + scene.closest_hit(&infinite).is_none(), + "an infinite bound is rejected for the same reason", + ); + let normal: Hit = scene + .closest_hit(&base) + .expect("a finite range is unaffected by those rejections"); + assert!( + (normal.get_t() - 1.0).abs() < EPSILON, + "the untouched ray still hits at t = 1.0, got {}", + normal.get_t(), + ); +} + +#[test] +fn degenerate_geometry_never_registers_a_hit() { + let material: Material = Material::lambert(Vector3D::new(0.5, 0.5, 0.5)); + let zero_radius: Occluder = Occluder::sphere(Vector3D::zero(), 0.0, material.clone()); + let negative_radius: Occluder = Occluder::sphere(Vector3D::zero(), -1.0, material.clone()); + let flat_box: Occluder = Occluder::aabb( + Vector3D::new(1.0, 1.0, 1.0), + Vector3D::new(1.0, 1.0, 1.0), + material.clone(), + ); + let collapsed_triangle: Occluder = Occluder::triangle( + Vector3D::new(2.0, 0.0, 0.0), + Vector3D::new(2.0, 0.0, 0.0), + Vector3D::new(2.0, 0.0, 0.0), + material, + ); + let scene: RayTraceScene = RayTraceScene::new(vec![ + zero_radius, + negative_radius, + flat_box, + collapsed_triangle, + ]); + let along_x: Ray = Ray::new(Vector3D::new(-10.0, 0.0, 0.0), Vector3D::new(1.0, 0.0, 0.0)); + assert!( + scene.closest_hit(&along_x).is_none(), + "a zero or negative radius, a flat box, and a collapsed triangle all have no surface a ray can strike", + ); + assert!( + !scene.occluded(&along_x), + "the any-hit query must agree that no degenerate surface blocks anything", + ); +} + +#[test] +fn a_non_finite_occluder_does_not_poison_its_neighbours() { + let good: Occluder = Occluder::sphere( + Vector3D::new(0.0, 0.0, 2.0), + 1.0, + Material::emissive(Vector3D::new(0.0, 1.0, 0.0)), + ); + let broken: Occluder = Occluder::sphere( + Vector3D::new(f64::NAN, 0.0, 2.0), + 1.0, + Material::emissive(Vector3D::new(1.0, 0.0, 0.0)), + ); + let scene: RayTraceScene = RayTraceScene::new(vec![good, broken]); + let ray: Ray = Ray::new(Vector3D::new(0.0, 0.0, 0.0), Vector3D::new(0.0, 0.0, 1.0)); + let hit: Hit = scene + .closest_hit(&ray) + .expect("the finite sphere is still reachable"); + assert!( + (hit.get_t() - 1.0).abs() < EPSILON, + "the finite sphere is entered at t = 1.0, got {}", + hit.get_t(), + ); + assert!( + (hit.get_normal().get_z() + 1.0).abs() < EPSILON, + "the nearest pole is the sphere's -z side, whose outward normal is (0, 0, -1), got ({}, {}, {})", + hit.get_normal().get_x(), + hit.get_normal().get_y(), + hit.get_normal().get_z(), + ); + let away: Ray = Ray::new(Vector3D::new(0.0, 0.0, 0.0), Vector3D::new(0.0, 1.0, 0.0)); + assert!( + scene.closest_hit(&away).is_none(), + "a ray off to the side must still miss both spheres", + ); + assert!(!scene.occluded(&away), "and the any-hit query must agree",); +} + +#[test] +fn a_large_scene_still_resolves_every_hit() { + let occluders: Vec = mixed_scene(600, 0x0bad_c0de); + let scene: RayTraceScene = RayTraceScene::new(occluders.clone()); + let mut state: u64 = 0x9999_8888; + let mut checked: u32 = 0; + for _ in 0..400 { + let raw: Vector3D = Vector3D::new(lcg(&mut state), lcg(&mut state), lcg(&mut state)); + let magnitude: f64 = raw.magnitude(); + if !magnitude.is_finite() || magnitude <= 1e-6 { + continue; + } + let origin: Vector3D = Vector3D::new( + lcg(&mut state) * 5.0, + lcg(&mut state) * 3.0 + 1.0, + lcg(&mut state) * 5.0, + ); + let ray: Ray = Ray::new(origin, raw.scaled(1.0 / magnitude)); + let expected: Option<(usize, f64)> = brute_hit(&ray, &occluders); + let actual: Option = scene.closest_hit(&ray); + checked += 1; + match (expected, actual) { + (None, None) => {} + (Some((_, expected_t)), Some(hit)) => assert!( + (hit.get_t() - expected_t).abs() < 1e-9, + "a 600-primitive hierarchy reported {} instead of {expected_t}", + hit.get_t(), + ), + (expected, actual) => { + panic!("a 600-primitive hierarchy returned {actual:?} instead of {expected:?}") + } + } + } + assert!(checked >= 380, "too few rays took part, got {checked}"); +} + +#[test] +fn a_single_occluder_scene_still_builds_a_usable_hierarchy() { + let sphere: Occluder = Occluder::sphere( + Vector3D::new(0.0, 0.0, 3.0), + 1.0, + Material::lambert(Vector3D::new(0.5, 0.5, 0.5)), + ); + let scene: RayTraceScene = RayTraceScene::new(vec![sphere]); + let ray: Ray = Ray::new(Vector3D::new(0.0, 0.0, 0.0), Vector3D::new(0.0, 0.0, 1.0)); + let hit: Hit = scene.closest_hit(&ray).expect("a lone sphere is reachable"); + assert!( + (hit.get_t() - 2.0).abs() < EPSILON, + "a lone sphere at z = 3 with radius 1 is entered at t = 2.0, got {}", + hit.get_t(), + ); + assert!( + scene.occluded(&ray), + "the same ray is blocked by the same sphere" + ); +} + +#[test] +fn an_empty_scene_reports_nothing_and_blocks_nothing() { + let scene: RayTraceScene = RayTraceScene::new(Vec::new()); + let ray: Ray = Ray::new(Vector3D::new(0.0, 0.0, 0.0), Vector3D::new(0.0, 0.0, 1.0)); + assert!( + scene.closest_hit(&ray).is_none(), + "an empty hierarchy has no node to visit, so there is nothing to hit", + ); + assert!( + !scene.occluded(&ray), + "and an empty hierarchy blocks nothing either", + ); +} + +#[test] +fn coincident_primitives_build_a_hierarchy_that_terminates() { + let material: Material = Material::lambert(Vector3D::new(0.5, 0.5, 0.5)); + let mut occluders: Vec = Vec::new(); + for _ in 0..24 { + occluders.push(Occluder::sphere(Vector3D::zero(), 1.0, material.clone())); + occluders.push(Occluder::aabb( + Vector3D::new(-1.0, -1.0, -1.0), + Vector3D::new(1.0, 1.0, 1.0), + material.clone(), + )); + } + let scene: RayTraceScene = RayTraceScene::new(occluders.clone()); + let ray: Ray = Ray::new(Vector3D::new(0.0, 0.0, 5.0), Vector3D::new(0.0, 0.0, -1.0)); + let expected: Option<(usize, f64)> = brute_hit(&ray, &occluders); + let hit: Hit = scene + .closest_hit(&ray) + .expect("coincident geometry is still hittable"); + assert!( + (hit.get_t() - 4.0).abs() < EPSILON, + "every primitive shares the same surface at t = 4.0, got {}", + hit.get_t(), + ); + assert!( + expected.is_some(), + "the brute-force reference agrees that the ray hits", + ); +} + +#[test] +fn a_geometric_spread_of_spheres_never_loses_the_nearest_hit() { + let mut occluders: Vec = Vec::new(); + let mut centers: Vec = Vec::new(); + for index in 0..80usize { + let center: f64 = 10.0f64.powf(2.0 * index as f64); + centers.push(center); + occluders.push(Occluder::sphere( + Vector3D::new(center, 0.0, 0.0), + center * 0.1, + Material::lambert(Vector3D::new(0.5, 0.5, 0.5)), + )); + } + let scene: RayTraceScene = RayTraceScene::new(occluders.clone()); + let ray: Ray = Ray::new(Vector3D::new(0.0, 0.0, 0.0), Vector3D::new(1.0, 0.0, 0.0)) + .with_range(0.001, 1e160); + let hit: Hit = scene + .closest_hit(&ray) + .expect("the first sphere is in range"); + let expected: f64 = centers[0] - centers[0] * 0.1; + assert!( + (hit.get_t() - expected).abs() < 1e-6 * expected, + "a geometric spread makes the heuristic chain deeply, and the nearest sphere at x = {} must still win; expected t = {} but got {}", + centers[0], + expected, + hit.get_t(), + ); + let expected_hit: Option<(usize, f64)> = brute_hit(&ray, &occluders); + assert_eq!( + expected_hit.map(|(_, t): (usize, f64)| t), + Some(hit.get_t()), + "the brute-force reference over all 80 spheres must agree", + ); +} + +#[test] +fn a_geometric_spread_of_spheres_never_loses_a_blocker() { + let mut occluders: Vec = Vec::new(); + let mut centers: Vec = Vec::new(); + for index in 0..80usize { + let center: f64 = 10.0f64.powf(2.0 * index as f64); + centers.push(center); + occluders.push(Occluder::sphere( + Vector3D::new(center, 0.0, 0.0), + center * 0.1, + Material::lambert(Vector3D::new(0.5, 0.5, 0.5)), + )); + } + let scene: RayTraceScene = RayTraceScene::new(occluders); + let long_ray: Ray = Ray::new(Vector3D::new(0.0, 0.0, 0.0), Vector3D::new(1.0, 0.0, 0.0)) + .with_range(0.001, 1e160); + assert!( + scene.occluded(&long_ray), + "the any-hit query must also survive the deep chain and find the nearest sphere", + ); + let short_ray: Ray = Ray::new(Vector3D::new(0.0, 0.0, 0.0), Vector3D::new(1.0, 0.0, 0.0)) + .with_range(0.001, centers[0] * 0.5); + assert!( + !scene.occluded(&short_ray), + "a segment stopping before the first sphere must stay clear even with 80 spheres behind it", + ); +} + +#[test] +fn a_blocker_beyond_the_light_cannot_shadow_the_light() { + let light_side: Occluder = Occluder::sphere( + Vector3D::new(0.0, 0.0, 3.0), + 0.5, + Material::lambert(Vector3D::new(0.5, 0.5, 0.5)), + ); + let beyond: Occluder = Occluder::sphere( + Vector3D::new(0.0, 0.0, 7.0), + 0.5, + Material::lambert(Vector3D::new(0.5, 0.5, 0.5)), + ); + let scene: RayTraceScene = RayTraceScene::new(vec![light_side, beyond]); + let to_light: Ray = + Ray::new(Vector3D::zero(), Vector3D::new(0.0, 0.0, 1.0)).with_range(0.001, 2.5); + assert!( + scene.occluded(&to_light), + "the sphere in front of the light blocks the segment that reaches it", + ); + let past_light: Ray = + Ray::new(Vector3D::zero(), Vector3D::new(0.0, 0.0, 1.0)).with_range(0.001, 2.4); + assert!( + !scene.occluded(&past_light), + "stopping short of the first sphere leaves nothing to block, even with one further out", + ); + let all_the_way: Ray = + Ray::new(Vector3D::zero(), Vector3D::new(0.0, 0.0, 1.0)).with_range(0.001, 8.0); + assert!( + scene.occluded(&all_the_way), + "an unbounded segment still hits both spheres", + ); +} + +#[test] +fn a_shadows_its_own_surface_only_through_the_offset() { + let material: Material = Material::lambert(Vector3D::new(0.5, 0.5, 0.5)); + let floor: Occluder = Occluder::aabb( + Vector3D::new(-10.0, -10.0, -0.5), + Vector3D::new(10.0, 10.0, 0.0), + material, + ); + let scene: RayTraceScene = RayTraceScene::new(vec![floor]); + let surface_point: Vector3D = Vector3D::new(1.0, 2.0, 0.0); + let offset: Ray = Ray::new(surface_point, Vector3D::new(0.0, 0.0, 1.0)) + .with_range(SHADOW_SELF_OFFSET, 1000.0); + assert!( + !scene.occluded(&offset), + "a ray leaving the surface along its normal must not hit the surface it started on", + ); + let back_into: Ray = Ray::new(surface_point, Vector3D::new(0.0, 0.0, -1.0)) + .with_range(SHADOW_SELF_OFFSET, 1000.0); + assert!( + scene.occluded(&back_into), + "the same surface does block a ray aimed back into it", + ); +} + +#[test] +fn an_occluded_segment_stops_shadow_rays_before_the_light() { + let blocker: Occluder = Occluder::sphere( + Vector3D::new(0.0, 0.0, 5.0), + 1.0, + Material::lambert(Vector3D::new(0.5, 0.5, 0.5)), + ); + let scene: RayTraceScene = RayTraceScene::new(vec![blocker]); + let clear: Ray = + Ray::new(Vector3D::new(0.0, 0.0, 0.0), Vector3D::new(0.0, 0.0, 1.0)).with_range(0.001, 3.0); + assert!( + !scene.occluded(&clear), + "a segment stopping before the sphere at t = 4.0 is clear", + ); + let blocked: Ray = Ray::new(Vector3D::new(0.0, 0.0, 0.0), Vector3D::new(0.0, 0.0, 1.0)) + .with_range(0.001, 10.0); + assert!( + scene.occluded(&blocked), + "the same segment reaching the sphere is blocked", + ); +} diff --git a/engine/tests/raytracing/fn.rs b/engine/tests/raytracing/fn.rs index 926711fd..3c33a247 100644 --- a/engine/tests/raytracing/fn.rs +++ b/engine/tests/raytracing/fn.rs @@ -503,27 +503,129 @@ fn trace_bounces_shades_a_triangle_occluder() { ); } +fn oracle_sphere_t( + origin: Vector3D, + dir: Vector3D, + center: Vector3D, + radius: f64, + t_min: f64, + t_max: f64, +) -> Option { + let oc: Vector3D = origin - center; + let a: f64 = dir.dot(dir); + if !a.is_finite() || a <= 0.0 { + return None; + } + let half_b: f64 = oc.dot(dir); + let c: f64 = oc.dot(oc) - radius * radius; + let discriminant: f64 = half_b * half_b - a * c; + if !discriminant.is_finite() || discriminant < 0.0 { + return None; + } + let root: f64 = discriminant.sqrt(); + let near: f64 = (-half_b - root) / a; + let far: f64 = (-half_b + root) / a; + if near >= t_min && near <= t_max { + return Some(near); + } + if far >= t_min && far <= t_max { + return Some(far); + } + None +} + +fn oracle_aabb_t( + origin: Vector3D, + dir: Vector3D, + lower: Vector3D, + upper: Vector3D, + t_min: f64, + t_max: f64, +) -> Option { + let lo: Vector3D = Vector3D::new( + lower.get_x().min(upper.get_x()), + lower.get_y().min(upper.get_y()), + lower.get_z().min(upper.get_z()), + ); + let hi: Vector3D = Vector3D::new( + lower.get_x().max(upper.get_x()), + lower.get_y().max(upper.get_y()), + lower.get_z().max(upper.get_z()), + ); + let mut t_enter: f64 = f64::NEG_INFINITY; + let mut t_exit: f64 = f64::INFINITY; + for axis in 0..3 { + let o: f64 = oracle_axis(origin, axis); + let d: f64 = oracle_axis(dir, axis); + let slab_lo: f64 = oracle_axis(lo, axis); + let slab_hi: f64 = oracle_axis(hi, axis); + if slab_lo >= slab_hi { + return None; + } + if d.abs() < 1e-300 { + if o < slab_lo || o > slab_hi { + return None; + } + continue; + } + let first: f64 = (slab_lo - o) / d; + let second: f64 = (slab_hi - o) / d; + let (near, far): (f64, f64) = if first <= second { + (first, second) + } else { + (second, first) + }; + t_enter = t_enter.max(near); + t_exit = t_exit.min(far); + } + if t_enter > t_exit { + return None; + } + let inside: bool = t_enter <= t_min && t_exit >= t_min; + let candidate: f64 = if inside { t_exit } else { t_enter }; + if candidate >= t_min && candidate <= t_max { + Some(candidate) + } else { + None + } +} + +fn oracle_axis(value: Vector3D, axis: usize) -> f64 { + let parts: [f64; 3] = [value.get_x(), value.get_y(), value.get_z()]; + parts[axis] +} + fn unbounded_closest_hit(ray: &Ray, occluders: &[Occluder]) -> Option { let origin: Vector3D = ray.get_origin(); let dir: Vector3D = ray.get_direction(); let t_min: f64 = ray.get_t_min(); let t_max: f64 = ray.get_t_max(); + if !t_min.is_finite() || !t_max.is_finite() || t_min > t_max { + return None; + } + let squared_length: f64 = dir.dot(dir); + if !squared_length.is_finite() || squared_length <= 0.0 { + return None; + } let mut best: Option = None; for occ in occluders.iter() { let candidate: Option = match occ.get_kind() { - OccluderKind::Sphere => { - let radius: f64 = occ.get_extent().get_x(); - match ray_sphere_intersect(origin, dir, occ.get_center(), radius) { - Some((t, _)) if t >= t_min && t <= t_max => Some(t), - _ => None, - } - } - OccluderKind::Aabb => { - match ray_aabb_intersect(origin, dir, occ.get_center(), occ.get_extent()) { - Some((t_near, _t_far, _)) if t_near >= t_min && t_near <= t_max => Some(t_near), - _ => None, - } - } + OccluderKind::Sphere => oracle_sphere_t( + origin, + dir, + occ.get_center(), + occ.get_extent().get_x(), + t_min, + t_max, + ), + OccluderKind::Aabb => oracle_aabb_t( + origin, + dir, + occ.get_center(), + occ.get_extent(), + t_min, + t_max, + ), OccluderKind::Triangle => { let vertices: [Vector3D; 3] = occ.get_vertices(); match intersect_triangle(origin, dir, vertices[0], vertices[1], vertices[2]) { @@ -533,8 +635,8 @@ fn unbounded_closest_hit(ray: &Ray, occluders: &[Occluder]) -> Option { } }; if let Some(t) = candidate { - let keep_previous: bool = matches!(&best, Some(previous) if *previous <= t); - if !keep_previous { + let keep_earlier: bool = matches!(best, Some(best_t) if best_t <= t); + if !keep_earlier { best = Some(t); } } @@ -607,7 +709,14 @@ fn broad_phase_matches_an_unpruned_scan_over_a_dense_scene() { (None, None) => {} (Some(e), Some(a)) => assert!( close_to(e, a), - "the broad phase returned t={a} but an unpruned scan of every occluder returns {e}", + "the broad phase returned t={a} but an unpruned scan of every occluder returns {e}; \ + ray origin=({}, {}, {}) dir=({}, {}, {})", + ray.get_origin().get_x(), + ray.get_origin().get_y(), + ray.get_origin().get_z(), + dir.get_x(), + dir.get_y(), + dir.get_z(), ), (e, a) => panic!("the broad phase returned {a:?} but an unpruned scan returns {e:?}"), } @@ -622,6 +731,77 @@ fn broad_phase_matches_an_unpruned_scan_over_a_dense_scene() { ); } +#[test] +fn a_ray_that_starts_inside_an_aabb_reports_the_exit_the_oracle_predicts() { + let box_low: Vector3D = Vector3D::new(-1.0, -1.0, -1.0); + let box_high: Vector3D = Vector3D::new(1.0, 1.0, 1.0); + let material: Material = Material::lambert(Vector3D::new(0.4, 0.4, 0.4)); + let occluders: Vec = vec![Occluder::aabb(box_low, box_high, material)]; + let scene: RayTraceScene = RayTraceScene::new(occluders.clone()); + for (origin, direction, expected_t) in [ + ( + Vector3D::new(0.0, 0.0, 0.0), + Vector3D::new(1.0, 0.0, 0.0), + 1.0, + ), + ( + Vector3D::new(0.0, 0.0, 0.0), + Vector3D::new(0.0, 0.0, -1.0), + 1.0, + ), + ( + Vector3D::new(0.5, -0.5, 0.25), + Vector3D::new(0.0, 1.0, 0.0), + 1.5, + ), + ( + Vector3D::new(0.5, -0.5, 0.25), + Vector3D::new(0.0, -1.0, 0.0), + 0.5, + ), + ] { + let ray: Ray = Ray::new(origin, direction); + let actual: f64 = scene + .closest_hit(&ray) + .expect("a ray starting inside the box must still hit it") + .get_t(); + assert!( + close_to(expected_t, actual), + "a ray starting inside the box must report the exit face, expected {expected_t} but got {actual} \ + for origin ({}, {}, {}) direction ({}, {}, {})", + origin.get_x(), + origin.get_y(), + origin.get_z(), + direction.get_x(), + direction.get_y(), + direction.get_z(), + ); + let expected: Option = unbounded_closest_hit(&ray, &occluders); + assert_eq!( + expected, + Some(actual), + "the independent scan must agree with the broad phase for an inside ray", + ); + } +} + +#[test] +fn a_ray_inside_an_aabb_past_the_far_bound_is_a_miss() { + let material: Material = Material::lambert(Vector3D::new(0.4, 0.4, 0.4)); + let scene: RayTraceScene = RayTraceScene::new(vec![Occluder::aabb( + Vector3D::new(-1.0, -1.0, -1.0), + Vector3D::new(1.0, 1.0, 1.0), + material, + )]); + let clipped: Ray = + Ray::new(Vector3D::zero(), Vector3D::new(1.0, 0.0, 0.0)).with_range(0.0, 0.5); + assert_eq!( + scene.closest_hit(&clipped).map(|hit: Hit| hit.get_t()), + None, + "the exit face lies beyond the clipped far bound, so the segment genuinely misses", + ); +} + #[test] fn a_ray_with_depth_keeps_its_geometry_and_only_changes_the_budget() { let ray: Ray = Ray::new(Vector3D::new(0.0, 0.0, 0.0), Vector3D::new(0.0, 0.0, -1.0)); diff --git a/engine/tests/raytracing/mod.rs b/engine/tests/raytracing/mod.rs index e41822a8..f8d8fa17 100644 --- a/engine/tests/raytracing/mod.rs +++ b/engine/tests/raytracing/mod.rs @@ -1,3 +1,4 @@ +mod r#bvh; mod r#fn; use super::*; diff --git a/engine/tests/renderer/fn.rs b/engine/tests/renderer/fn.rs index e3e2c48e..422e6469 100644 --- a/engine/tests/renderer/fn.rs +++ b/engine/tests/renderer/fn.rs @@ -725,17 +725,17 @@ fn the_named_render_layers_are_ordered_background_first() { fn the_default_gl_state_seeds_its_viewport_from_the_given_dimensions() { let state: GlRenderState = GlRenderState::context_defaults(321, 654); assert_eq!( - *state.get_viewport().get_width(), + state.get_viewport().get_width(), 321, "an unusual width cannot be confused with a baked-in default" ); - assert_eq!(*state.get_viewport().get_height(), 654); + assert_eq!(state.get_viewport().get_height(), 654); assert_eq!( - *state.get_viewport().get_x(), + state.get_viewport().get_x(), 0, "the origin is not part of the arguments" ); - assert_eq!(*state.get_viewport().get_y(), 0); + assert_eq!(state.get_viewport().get_y(), 0); } #[test] @@ -743,11 +743,11 @@ fn two_different_default_states_do_not_share_a_viewport() { let narrow: GlRenderState = GlRenderState::context_defaults(100, 200); let wide: GlRenderState = GlRenderState::context_defaults(1600, 900); assert_ne!( - *narrow.get_viewport().get_width(), - *wide.get_viewport().get_width(), + narrow.get_viewport().get_width(), + wide.get_viewport().get_width(), "a cached or hardcoded viewport would make these equal" ); - assert_eq!(*wide.get_viewport().get_height(), 900); + assert_eq!(wide.get_viewport().get_height(), 900); } #[test] diff --git a/engine/tests/webgl_backend/fn.rs b/engine/tests/webgl_backend/fn.rs index c88d09e5..2224f090 100644 --- a/engine/tests/webgl_backend/fn.rs +++ b/engine/tests/webgl_backend/fn.rs @@ -179,7 +179,7 @@ fn the_default_state_a_fresh_backend_starts_with_disables_depth_testing() { "scissoring starts off, and the off case is an absent rectangle rather than a zero one" ); assert_eq!( - *fresh.get_color_mask().get_bits(), + fresh.get_color_mask().get_bits(), 0xf, "all four channels are writable until a draw says otherwise" ); diff --git a/example/src/component/bridge/hook/struct.rs b/example/src/component/bridge/hook/struct.rs index e69edd8b..027d0a05 100644 --- a/example/src/component/bridge/hook/struct.rs +++ b/example/src/component/bridge/hook/struct.rs @@ -6,13 +6,19 @@ use super::*; #[derive(Clone, Copy, Data, New)] pub(crate) struct UseEuvNativeBridge { /// Whether the native bridge is available on this platform. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) available: Signal, /// Whether data is currently being loaded from the native bridge. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) loading: Signal, /// The resolved bridge group permissions. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) permissions: Signal, } @@ -26,19 +32,27 @@ pub(crate) struct UseEuvNativeBridge { #[derive(Clone, Copy, Data, New)] pub(crate) struct UseCacheUpdate { /// Whether the docs.rs build status indicates documentation is available. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) doc_status: Signal, /// The latest version string returned by docs.rs. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) version: Signal, /// Whether a cache update operation is currently in progress. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) updating: Signal, /// Most recent `data` payload from the native `update_cache` bridge. /// /// On `Success` this is the freshly minted snapshot name (e.g. /// `v_1731321600123`); on `Failed` it is an empty string. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) data: Signal, /// Most recent `message` payload from the native `update_cache` bridge. /// @@ -46,7 +60,9 @@ pub(crate) struct UseCacheUpdate { /// on `Failed` it carries the underlying error from the cache layer. /// Surfacing it here lets the UI show the native error verbatim /// instead of inventing its own message. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) message: Signal, } @@ -60,17 +76,30 @@ pub(crate) struct UseCacheUpdate { #[derive(Clone, Data, New)] pub(crate) struct UpdateResult { /// Whether the documentation build succeeded. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) doc_status: bool, /// The latest version string of the crate. + #[get(pub(crate))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) version: String, /// Whether a cache update operation was currently in progress when /// this result was produced. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) updating: bool, /// Snapshot version name reported by the native bridge (empty on failure). + #[get(pub(crate))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) data: String, /// Human-readable status / error message reported by the native bridge. + #[get(pub(crate))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) message: String, } @@ -89,12 +118,21 @@ pub(crate) struct UpdateResult { pub(crate) struct UpdateResultPayload { /// Outcome tag deserialized as an enum, so an unknown tag fails the /// whole payload rather than silently mis-classifying. + #[get(pub(crate))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) result: UpdateStatus, /// Snapshot version name reported by the native side; empty on failure. /// Propagated into `UpdateResult::data` for the UI to surface. + #[get(pub(crate))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) data: String, /// Human-readable status / error message from the native side. Propagated /// into `UpdateResult::message` so the UI shows the native error verbatim. + #[get(pub(crate))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) message: String, } @@ -104,12 +142,18 @@ pub(crate) struct UpdateResultPayload { #[derive(Clone, Data, Debug, New)] pub(crate) struct BridgeConfig { /// The global bridge object key on window. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) global_key: &'static str, /// The core module key. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) core_key: &'static str, /// The invoke function key. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) invoke_key: &'static str, } diff --git a/example/src/component/layout/view/struct.rs b/example/src/component/layout/view/struct.rs index 60026f4f..db30e831 100644 --- a/example/src/component/layout/view/struct.rs +++ b/example/src/component/layout/view/struct.rs @@ -38,8 +38,13 @@ pub(crate) struct MobileLayoutProps { #[derive(Clone, Data, Default, Deserialize, New)] pub(crate) struct DocsStatus { /// Whether the documentation build succeeded. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) doc_status: bool, /// The latest version string of the crate. + #[get(pub(crate))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) version: String, } diff --git a/example/src/page/animation/hook/struct.rs b/example/src/page/animation/hook/struct.rs index 43dc81bd..6bc81d2d 100644 --- a/example/src/page/animation/hook/struct.rs +++ b/example/src/page/animation/hook/struct.rs @@ -4,6 +4,8 @@ use super::*; #[derive(Clone, Copy, Data, Debug, Default, Eq, Hash, New, Ord, PartialEq, PartialOrd)] pub(crate) struct UseProgress { /// Whether the progress bar animation is currently running. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) running: Signal, } diff --git a/example/src/page/binding/hook/struct.rs b/example/src/page/binding/hook/struct.rs index 0953672d..fe30ce80 100644 --- a/example/src/page/binding/hook/struct.rs +++ b/example/src/page/binding/hook/struct.rs @@ -4,7 +4,9 @@ use super::*; #[derive(Clone, Copy, Data, Debug, Default, Eq, Hash, New, Ord, PartialEq, PartialOrd)] pub(crate) struct UsePropsDemo { /// The parent message to pass down to child. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) parent_message: Signal, } @@ -12,10 +14,14 @@ pub(crate) struct UsePropsDemo { #[derive(Clone, Copy, Data, Debug, Default, Eq, Hash, New, Ord, PartialEq, PartialOrd)] pub(crate) struct UseTwoWayDemo { /// The shared text value bound between parent and child. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) shared_text: Signal, /// The shared counter value bound between parent and child. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) shared_count: Signal, } @@ -23,22 +29,34 @@ pub(crate) struct UseTwoWayDemo { #[derive(Clone, Copy, Data, Debug, Default, New, PartialEq, PartialOrd)] pub(crate) struct UseCrossComponentDemo { /// The temperature in Celsius. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) celsius: Signal, /// The temperature in Fahrenheit (derived). - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) fahrenheit: Signal, /// The RGB red channel value. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) red: Signal, /// The RGB green channel value. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) green: Signal, /// The RGB blue channel value. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) blue: Signal, /// The hex color string (derived). - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) hex_color: Signal, } @@ -48,12 +66,18 @@ pub(crate) struct UseCrossComponentDemo { #[derive(Clone, Copy, Data, Debug, Default, Eq, Hash, New, Ord, PartialEq, PartialOrd)] pub(crate) struct UseTypedPropsDemo { /// Whether the child input is disabled. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) disabled: Signal, /// The maximum count allowed. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) max_count: Signal, /// The current count value. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) current_count: Signal, } diff --git a/example/src/page/canvas/hook/struct.rs b/example/src/page/canvas/hook/struct.rs index 1b4a18eb..748df995 100644 --- a/example/src/page/canvas/hook/struct.rs +++ b/example/src/page/canvas/hook/struct.rs @@ -8,28 +8,44 @@ use super::*; #[derive(Clone, Copy, Data, Debug, Default, PartialEq)] pub(crate) struct UseCanvas { /// Whether the user is currently drawing on the canvas. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) drawing: Signal, /// The current stroke color as a CSS color string. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) stroke_color: Signal, /// The current line width in pixels. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) line_width: Signal, /// Whether the canvas is currently in fullscreen mode. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) fullscreen: Signal, /// The data URL of the current canvas snapshot for preview. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) snapshot_data_url: Signal, /// The x coordinate of the last pointer position during drawing. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) last_x: Signal, /// The y coordinate of the last pointer position during drawing. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) last_y: Signal, /// A map from touch identifier to its last known canvas coordinates, /// used to draw continuous line segments for each touch point. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) touch_last_points: Signal>, } diff --git a/example/src/page/event/hook/struct.rs b/example/src/page/event/hook/struct.rs index df39cf30..a4c9e779 100644 --- a/example/src/page/event/hook/struct.rs +++ b/example/src/page/event/hook/struct.rs @@ -4,19 +4,29 @@ use super::*; #[derive(Clone, Copy, Data, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] pub(crate) struct UseKeyboardEvent { /// The last key pressed down. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) last_key: Signal, /// The last key code pressed down. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) last_key_code: Signal, /// The last key released up. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) last_key_up: Signal, /// Whether the key press is a repeat. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) key_repeat: Signal, /// The active modifier keys string. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) modifier: Signal, } @@ -24,40 +34,64 @@ pub(crate) struct UseKeyboardEvent { #[derive(Clone, Copy, Data, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] pub(crate) struct UseMouseEvent { /// The total click count. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) click_count: Signal, /// The total double-click count. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) double_click_count: Signal, /// The client position string. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) mouse_pos: Signal, /// The screen position string. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) mouse_screen_pos: Signal, /// The mouse button name. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) mouse_button: Signal, /// The mouse buttons bitmask string. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) mouse_buttons: Signal, /// The mouse enter count. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) mouse_enter_count: Signal, /// The mouse leave count. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) mouse_leave_count: Signal, /// The mouse over count. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) mouse_over_count: Signal, /// The mouse out count. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) mouse_out_count: Signal, /// The mouse down count. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) mouse_down_count: Signal, /// The mouse up count. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) mouse_up_count: Signal, } @@ -65,13 +99,19 @@ pub(crate) struct UseMouseEvent { #[derive(Clone, Copy, Data, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] pub(crate) struct UseFocusEvent { /// The current focus status string. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) focus_status: Signal, /// The focus-in count. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) focus_in_count: Signal, /// The focus-out count. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) focus_out_count: Signal, } @@ -79,26 +119,38 @@ pub(crate) struct UseFocusEvent { #[derive(Clone, Copy, Data, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] pub(crate) struct UseDragEvent { /// The current drag status string. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) drag_status: Signal, /// The drag position string. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) drag_pos: Signal, /// The drag data types string. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) drag_types: Signal, /// The counter used to track whether the cursor is inside the drop zone. /// /// Incremented on `dragenter`, decremented on `dragleave`. /// Non-zero means the cursor is inside the zone, avoiding false /// triggers when moving between child elements. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) drag_enter_counter: Signal, /// The pending drag position to be flushed on the next animation frame. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) drag_pending_pos: Signal, /// The request-animation-frame ID used for throttling drag position updates. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) drag_raf_id: Signal, } @@ -106,10 +158,14 @@ pub(crate) struct UseDragEvent { #[derive(Clone, Copy, Data, Debug, PartialEq)] pub(crate) struct UseWheelEvent { /// The wheel delta string. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) wheel_delta: Signal, /// The accumulated vertical scroll total. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) wheel_total: Signal, } @@ -117,10 +173,14 @@ pub(crate) struct UseWheelEvent { #[derive(Clone, Copy, Data, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] pub(crate) struct UseClipboardEvent { /// The clipboard data content. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) clipboard_data: Signal, /// The clipboard event type name. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) clipboard_event_type: Signal, } @@ -128,7 +188,9 @@ pub(crate) struct UseClipboardEvent { #[derive(Clone, Copy, Data, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] pub(crate) struct UseTouchEvent { /// The touch information string. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) touch_info: Signal, } @@ -136,19 +198,29 @@ pub(crate) struct UseTouchEvent { #[derive(Clone, Copy, Data, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] pub(crate) struct UseFormEvent { /// The input value from oninput. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) euv_input_value: Signal, /// The change value from onchange. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) form_change_value: Signal, /// The checkbox checked state. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) form_checkbox: Signal, /// The select value from onchange. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) form_select_value: Signal, /// The form submit count. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) submit_count: Signal, } @@ -156,10 +228,14 @@ pub(crate) struct UseFormEvent { #[derive(Clone, Copy, Data, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] pub(crate) struct UseMediaEvent { /// The media playback status. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) media_status: Signal, /// The last media event log. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) media_event_log: Signal, } @@ -167,22 +243,34 @@ pub(crate) struct UseMediaEvent { #[derive(Clone, Copy, Data, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] pub(crate) struct UseVideoEvent { /// The video playback status. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) video_status: Signal, /// The last video event log. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) video_event_log: Signal, /// The current playback time string. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) video_current_time: Signal, /// The video duration string. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) video_duration: Signal, /// The buffered percentage string. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) video_buffered: Signal, /// The playback rate string. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) video_playback_rate: Signal, } @@ -190,12 +278,18 @@ pub(crate) struct UseVideoEvent { #[derive(Clone, Copy, Data, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] pub(crate) struct UseImageEvent { /// The image load status. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) image_status: Signal, /// The last image event log. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) image_event_log: Signal, /// The image natural size string. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) image_natural_size: Signal, } diff --git a/example/src/page/file/hook/struct.rs b/example/src/page/file/hook/struct.rs index 29ad8846..f9e91faa 100644 --- a/example/src/page/file/hook/struct.rs +++ b/example/src/page/file/hook/struct.rs @@ -4,21 +4,33 @@ use super::*; #[derive(Clone, Copy, Data, Debug, Default, New, PartialEq)] pub(crate) struct UseFileUpload { /// The names of selected files. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) file_names: Signal>, /// The sizes of selected files in bytes. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) file_sizes: Signal>, /// The MIME types of selected files. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) file_types: Signal>, /// Whether multiple file selection is enabled. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) multiple: Signal, /// The accepted file types filter string. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) accept: Signal, /// The status message. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) status: Signal, } diff --git a/example/src/page/form/hook/struct.rs b/example/src/page/form/hook/struct.rs index 280b7187..7160faf1 100644 --- a/example/src/page/form/hook/struct.rs +++ b/example/src/page/form/hook/struct.rs @@ -4,33 +4,53 @@ use super::*; #[derive(Clone, Copy, Data, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] pub(crate) struct UseForm { /// The username input. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) username: Signal, /// The email input. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) email: Signal, /// The password input. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) password: Signal, /// The agree checkbox state. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) agree: Signal, /// The submission result message. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) submitted: Signal, /// The validation error message. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) errors: Signal, /// The username validation error message. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) username_error: Signal, /// The email validation error message. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) email_error: Signal, /// The password validation error message. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) password_error: Signal, /// The agree validation error message. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) agree_error: Signal, } diff --git a/example/src/page/game_2d/hook/struct.rs b/example/src/page/game_2d/hook/struct.rs index a3d813cd..fd8ed89d 100644 --- a/example/src/page/game_2d/hook/struct.rs +++ b/example/src/page/game_2d/hook/struct.rs @@ -17,19 +17,29 @@ pub(crate) struct Ball { #[derive(Clone, Copy, Data, Debug, Default, PartialEq)] pub(crate) struct UseGame2D { /// Whether the 2D game loop is currently running. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) running: Signal, /// The current frames-per-second measurement. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) fps: Signal, /// The current number of balls on the canvas. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) ball_count: Signal, /// The total number of balls spawned since the 2D game started. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) total_spawned: Signal, /// Whether the canvas has finished loading and is ready for interaction. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) loaded: Signal, } @@ -45,16 +55,24 @@ pub(crate) struct CanvasCache(pub(crate) Rc>>) #[derive(Clone, Copy, Data, Debug, Default, PartialEq)] pub(crate) struct UseGame2DWebGpu { /// The current frames-per-second measurement. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) fps: Signal, /// Whether the WebGPU renderer has finished initializing (success or failure). - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) loaded: Signal, /// Whether the WebGPU renderer is active and rendering. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) active: Signal, /// Whether the WebGPU render loop has been kicked off in this component tree. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) loop_started: Signal, /// The most recent init error code as a stable string. /// @@ -63,7 +81,9 @@ pub(crate) struct UseGame2DWebGpu { /// still in flight or has not started). Storing a stable code rather /// than the full `WebGpuInitError` keeps this state `Copy` and avoids /// surfacing JS error detail into the reactive UI tree. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) init_error_code: Signal<&'static str>, } @@ -71,21 +91,31 @@ pub(crate) struct UseGame2DWebGpu { #[derive(Clone, Copy, Data, Debug, Default, PartialEq)] pub(crate) struct UseGame2DWebGl { /// The current frames-per-second measurement. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) fps: Signal, /// Whether the WebGL renderer has finished initializing (success or failure). - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) loaded: Signal, /// Whether the WebGL renderer is active and rendering. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) active: Signal, /// Whether the WebGL render loop has been kicked off in this component tree. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) loop_started: Signal, /// The most recent init error code as a stable string. /// /// Empty string means "no error" (init still in flight or not started). - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) init_error_code: Signal<&'static str>, } @@ -100,12 +130,18 @@ pub(crate) struct UseGame2DWebGl { #[derive(Clone, Copy, Data, Debug, Default, PartialEq)] pub(crate) struct UseGame2DFullscreen { /// Whether the Canvas 2D tab is currently in landscape fullscreen. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) canvas_2d: Signal, /// Whether the WebGL tab is currently in landscape fullscreen. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) web_gl: Signal, /// Whether the WebGPU tab is currently in landscape fullscreen. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) web_gpu: Signal, } diff --git a/example/src/page/game_3d/hook/struct.rs b/example/src/page/game_3d/hook/struct.rs index 1cef6685..e2fdae50 100644 --- a/example/src/page/game_3d/hook/struct.rs +++ b/example/src/page/game_3d/hook/struct.rs @@ -25,19 +25,29 @@ pub(crate) struct Cube3D { #[derive(Clone, Copy, Data, Debug, Default, PartialEq)] pub(crate) struct UseGame3D { /// Whether the game loop is currently running. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) running: Signal, /// The current frames-per-second measurement. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) fps: Signal, /// The current number of cubes in the scene. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) cube_count: Signal, /// Whether auto-rotation is enabled. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) auto_rotate: Signal, /// Whether the canvas has finished loading and is ready for interaction. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) loaded: Signal, } @@ -62,16 +72,24 @@ pub(crate) struct CubeStore(pub(crate) Rc>>); #[derive(Clone, Copy, Data, Debug, Default, PartialEq)] pub(crate) struct UseGame3DWebGpu { /// The current frames-per-second measurement. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) fps: Signal, /// Whether the WebGPU renderer has finished initializing (success or failure). - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) loaded: Signal, /// Whether the WebGPU renderer is active and rendering. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) active: Signal, /// Whether the WebGPU render loop has been kicked off in this component tree. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) loop_started: Signal, /// The most recent init error code as a stable string. /// @@ -80,7 +98,9 @@ pub(crate) struct UseGame3DWebGpu { /// still in flight or has not started). Storing a stable code rather /// than the full `WebGpuInitError` keeps this state `Copy` and avoids /// surfacing JS error detail into the reactive UI tree. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) init_error_code: Signal<&'static str>, } @@ -88,21 +108,31 @@ pub(crate) struct UseGame3DWebGpu { #[derive(Clone, Copy, Data, Debug, Default, PartialEq)] pub(crate) struct UseGame3DWebGl { /// The current frames-per-second measurement. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) fps: Signal, /// Whether the WebGL renderer has finished initializing (success or failure). - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) loaded: Signal, /// Whether the WebGL renderer is active and rendering. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) active: Signal, /// Whether the WebGL render loop has been kicked off in this component tree. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) loop_started: Signal, /// The most recent init error code as a stable string. /// /// Empty string means "no error" (init still in flight or not started). - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) init_error_code: Signal<&'static str>, } @@ -117,12 +147,18 @@ pub(crate) struct UseGame3DWebGl { #[derive(Clone, Copy, Data, Debug, Default, PartialEq)] pub(crate) struct UseGame3DFullscreen { /// Whether the Canvas 2D tab is currently in landscape fullscreen. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) canvas_2d: Signal, /// Whether the WebGL tab is currently in landscape fullscreen. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) web_gl: Signal, /// Whether the WebGPU tab is currently in landscape fullscreen. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) web_gpu: Signal, } diff --git a/example/src/page/lifecycle/hook/struct.rs b/example/src/page/lifecycle/hook/struct.rs index 89f5fb97..d92dff18 100644 --- a/example/src/page/lifecycle/hook/struct.rs +++ b/example/src/page/lifecycle/hook/struct.rs @@ -4,9 +4,13 @@ use super::*; #[derive(Clone, Copy, Data, Debug, Default, Eq, Hash, New, Ord, PartialEq, PartialOrd)] pub(crate) struct UseLifecycle { /// The render count. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) render_count: Signal, /// The event log entries. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) logs: Signal>, } diff --git a/example/src/page/lighting/hook/struct.rs b/example/src/page/lighting/hook/struct.rs index 5d978cd4..be8851a9 100644 --- a/example/src/page/lighting/hook/struct.rs +++ b/example/src/page/lighting/hook/struct.rs @@ -23,23 +23,35 @@ pub(crate) struct LightingSphere { #[derive(Clone, Copy, Data, Debug, Default, PartialEq)] pub(crate) struct UseLighting { /// The current frames-per-second measurement. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) fps: Signal, /// Whether the lighting loop is currently running. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) running: Signal, /// Whether the Canvas 2D renderer has finished initializing (success or failure). - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) loaded: Signal, /// Whether the Canvas 2D renderer is active and rendering. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) active: Signal, /// Whether the lighting loop has been kicked off in this component tree. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) loop_started: Signal, /// The current adaptive internal render scale (1.0 = full 320x240, /// 2.0 = 640x480 super-sampled). - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) render_scale: Signal, /// The most recent init error code as a stable string. /// @@ -48,7 +60,9 @@ pub(crate) struct UseLighting { /// The empty string means "no error" (i.e. init has not started or /// succeeded). Storing a stable code rather than the full error /// type keeps this state `Copy`. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) init_error_code: Signal<&'static str>, } @@ -56,19 +70,29 @@ pub(crate) struct UseLighting { #[derive(Clone, Copy, Data, Debug, Default, PartialEq)] pub(crate) struct UseLightingWebGl { /// The current frames-per-second measurement. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) fps: Signal, /// Whether the WebGL lighting loop is currently running. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) running: Signal, /// Whether the WebGL renderer has finished initializing (success or failure). - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) loaded: Signal, /// Whether the WebGL renderer is active and rendering. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) active: Signal, /// Whether the WebGL render loop has been kicked off in this component tree. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) loop_started: Signal, /// The most recent init error code as a stable string. /// @@ -77,7 +101,9 @@ pub(crate) struct UseLightingWebGl { /// still in flight or has not started). Storing a stable code rather /// than the full `WebGl2InitError` keeps this state `Copy` and avoids /// surfacing JS error detail into the reactive UI tree. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) init_error_code: Signal<&'static str>, } @@ -85,19 +111,29 @@ pub(crate) struct UseLightingWebGl { #[derive(Clone, Copy, Data, Debug, Default, PartialEq)] pub(crate) struct UseLightingWebGpu { /// The current frames-per-second measurement. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) fps: Signal, /// Whether the WebGPU lighting loop is currently running. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) running: Signal, /// Whether the WebGPU renderer has finished initializing (success or failure). - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) loaded: Signal, /// Whether the WebGPU renderer is active and rendering. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) active: Signal, /// Whether the WebGPU render loop has been kicked off in this component tree. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) loop_started: Signal, /// The most recent init error code as a stable string. /// @@ -106,7 +142,9 @@ pub(crate) struct UseLightingWebGpu { /// still in flight or has not started). Storing a stable code rather /// than the full `WebGpuInitError` keeps this state `Copy` and avoids /// surfacing JS error detail into the reactive UI tree. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) init_error_code: Signal<&'static str>, } @@ -121,12 +159,18 @@ pub(crate) struct UseLightingWebGpu { #[derive(Clone, Copy, Data, Debug, Default, PartialEq)] pub(crate) struct UseLightingFullscreen { /// Whether the Canvas 2D tab is currently in landscape fullscreen. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) canvas_2d: Signal, /// Whether the WebGL tab is currently in landscape fullscreen. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) web_gl: Signal, /// Whether the WebGPU tab is currently in landscape fullscreen. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) web_gpu: Signal, } diff --git a/example/src/page/list/hook/struct.rs b/example/src/page/list/hook/struct.rs index 135a9dc4..3463b592 100644 --- a/example/src/page/list/hook/struct.rs +++ b/example/src/page/list/hook/struct.rs @@ -4,12 +4,18 @@ use super::*; #[derive(Clone, Copy, Data, Debug, Default, Eq, Hash, New, Ord, PartialEq, PartialOrd)] pub(crate) struct UseTodoList { /// The list of todo items. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) items: Signal>, /// The new item input text. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) new_item: Signal, /// The validation error message for add operation. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) add_error: Signal, } diff --git a/example/src/page/modal/hook/struct.rs b/example/src/page/modal/hook/struct.rs index 7d8cac8d..50dc2145 100644 --- a/example/src/page/modal/hook/struct.rs +++ b/example/src/page/modal/hook/struct.rs @@ -4,42 +4,68 @@ use super::*; #[derive(Clone, Copy, Data, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] pub(crate) struct UseModal { /// Whether the basic modal is visible. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) show_basic: Signal, /// Whether the confirm modal is visible. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) show_confirm: Signal, /// Whether the form modal is visible. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) show_form: Signal, /// Whether the first (outermost) nested modal layer is visible. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) show_nested_1: Signal, /// Whether the second nested modal layer is visible. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) show_nested_2: Signal, /// Whether the third (innermost) nested modal layer is visible. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) show_nested_3: Signal, /// The confirm action result message. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) confirm_result: Signal, /// The modal form name input. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) modal_name: Signal, /// The modal form email input. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) modal_email: Signal, /// The modal form submission result. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) modal_submitted: Signal, /// The modal form validation error message. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) modal_error: Signal, /// The modal form name validation error message. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) name_error: Signal, /// The modal form email validation error message. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) email_error: Signal, } diff --git a/example/src/page/raytrace/hook/const.rs b/example/src/page/raytrace/hook/const.rs index 20331771..086073fb 100644 --- a/example/src/page/raytrace/hook/const.rs +++ b/example/src/page/raytrace/hook/const.rs @@ -1,128 +1,107 @@ -/// The CSS selector used to query the RayTrace demo canvas element from the DOM. +/// The CSS selector used to query the RayTrace Canvas 2D canvas element +/// from the DOM. pub(crate) const RAYTRACE_CANVAS_SELECTOR: &str = "#raytrace-canvas"; /// The CSS selector for the RayTrace Canvas 2D loading overlay canvas. pub(crate) const RAYTRACE_LOADING_CANVAS_SELECTOR: &str = "#raytrace-loading-canvas"; -/// The Canvas 2D context type identifier passed to `HTMLCanvasElement::get_context`. -pub(crate) const RAYTRACE_CONTEXT_TYPE: &str = "2d"; +/// The CSS selector used to query the RayTrace WebGL canvas element. +pub(crate) const RAYTRACE_WEBGL_CANVAS_SELECTOR: &str = "#raytrace-webgl-canvas"; -/// Minimum visible duration in milliseconds for the RayTrace Canvas 2D -/// tab's loading overlay. -/// -/// Mirrors `GAME_3D_LOADING_MIN_MILLIS` / `raytrace_set_loaded_delayed` -/// (used by the WebGL / WebGPU tabs) so the user always sees the -/// "Initializing..." text for at least one paint even when the SSAA -/// acquire + first warmup ray pass finishes faster than a single -/// frame. Without this floor the overlay would mount and unmount -/// inside the same `requestAnimationFrame` tick, which most browsers -/// collapse into a single paint and the user never sees the loading -/// state at all. -pub(crate) const RAYTRACE_CANVAS_2D_LOADING_MIN_MILLIS: i32 = 400; - -/// Logical width of the RayTrace page's offscreen render buffer at full -/// render scale. -/// -/// The buffer is sized so a full per-pixel software ray pass finishes -/// well under 16ms per frame on a mid-range laptop. The CSS box scales -/// the buffer to fit the visible canvas via the `c_game_3d_canvas` -/// style. -pub(crate) const RAYTRACE_WIDTH: f64 = 320.0; +/// The CSS selector for the RayTrace WebGL loading overlay canvas. +pub(crate) const RAYTRACE_WEBGL_LOADING_CANVAS_SELECTOR: &str = "#raytrace-webgl-loading-canvas"; -/// Logical height of the RayTrace page's offscreen render buffer at full -/// render scale. -pub(crate) const RAYTRACE_HEIGHT: f64 = 240.0; +/// The CSS selector used to query the RayTrace WebGPU canvas element. +pub(crate) const RAYTRACE_WEBGPU_CANVAS_SELECTOR: &str = "#raytrace-webgpu-canvas"; -/// The orbit yaw speed in radians per second for auto-rotation. -/// -/// Mirrors the same constant in the 3D game page so the two demos feel -/// visually consistent when both are visible in the sidebar. -pub(crate) const RAYTRACE_AUTO_YAW_SPEED: f64 = 0.5; +/// The CSS selector for the RayTrace WebGPU loading overlay canvas. +pub(crate) const RAYTRACE_WEBGPU_LOADING_CANVAS_SELECTOR: &str = "#raytrace-webgpu-loading-canvas"; -/// The minimum angle in radians between the camera pitch and +/- pi/2. +/// The stable error code recorded when +/// [`RayTraceRenderer::init`](euv_engine::RayTraceRenderer) rejects the +/// requested backend. /// -/// Prevents the orbit camera from looking straight up or down, which -/// would collapse the `forward x up` cross product and zero the view -/// matrix. -pub(crate) const RAYTRACE_PITCH_CLAMP: f64 = 0.01; - -/// The sensitivity multiplier applied to pointer drag deltas before -/// they are folded into orbit angles. +/// Distinct from [`RAYTRACE_ERROR_CODE_RENDER`] so the status banner can +/// tell "this browser cannot host the backend at all" apart from "the +/// backend was acquired and then failed mid-run". +pub(crate) const RAYTRACE_ERROR_CODE_INIT: &str = "RAYTRACE_INIT"; + +/// The stable error code recorded when +/// [`RayTraceRenderer::render`](euv_engine::RayTraceRenderer) rejects a +/// frame. +pub(crate) const RAYTRACE_ERROR_CODE_RENDER: &str = "RAYTRACE_RENDER"; + +/// The stable error code recorded when +/// [`RayTraceRenderer::resize`](euv_engine::RayTraceRenderer) rejects new +/// dimensions. +pub(crate) const RAYTRACE_ERROR_CODE_RESIZE: &str = "RAYTRACE_RESIZE"; + +/// The fallback detail text used when a rejected engine call carries a +/// `JsValue` that is not a string (a thrown object, a number, ...). +pub(crate) const RAYTRACE_ERROR_DETAIL_UNKNOWN: &str = "engine returned a non-string error"; + +/// The log prefix every RayTrace engine diagnostic is written with. +pub(crate) const RAYTRACE_LOG_PREFIX: &str = "[euv-engine][raytrace]"; + +/// The message written to the console when a backend finishes +/// initializing. +pub(crate) const RAYTRACE_LOG_BACKEND_READY: &str = "backend ready"; + +/// Minimum visible duration in milliseconds for a RayTrace tab's loading +/// overlay. /// -/// Matches the value used by the 3D game page's pointer handlers so the -/// two demos feel identical in drag responsiveness. -pub(crate) const RAYTRACE_DRAG_SENSITIVITY: f64 = 0.01; - -/// The radius of the orbit sphere on which the camera sits. +/// A backend can finish `init` well inside a single +/// `requestAnimationFrame` tick, and most browsers collapse a mount + +/// unmount that happens inside one tick into a single paint. The overlay +/// would then never be seen at all, so the `loaded` flag is flipped on a +/// timer instead of immediately, which guarantees the user sees the +/// "Initializing..." line for every tab. +pub(crate) const RAYTRACE_LOADING_MIN_MILLIS: i32 = 400; + +/// Logical width of the RayTrace Canvas 2D tab's internal framebuffer at +/// render scale 1.0. /// -/// Mirrors `GAME_3D_CAMERA_DISTANCE` so the user can compare the two -/// orbit-camera demos at equivalent zoom levels. -pub(crate) const RAYTRACE_CAMERA_DISTANCE: f64 = 8.0; - -/// The y-coordinate of the orbit sphere's centre (the scene's -/// look-at target vertical position). -pub(crate) const RAYTRACE_CAMERA_LOOK_AT_Y: f64 = 0.4; - -/// The z-coordinate of the orbit sphere's centre (the scene's -/// look-at target depth). -pub(crate) const RAYTRACE_CAMERA_LOOK_AT_Z: f64 = 0.0; - -/// The JavaScript property name for the touch list `touches` on a -/// `TouchEvent`. -pub(crate) const RAYTRACE_EVENT_PROPERTY_TOUCHES: &str = "touches"; - -/// The JavaScript property name for the client X coordinate on a -/// `Touch` object. -pub(crate) const RAYTRACE_EVENT_PROPERTY_CLIENT_X: &str = "clientX"; - -/// The JavaScript property name for the client Y coordinate on a -/// `Touch` object. -pub(crate) const RAYTRACE_EVENT_PROPERTY_CLIENT_Y: &str = "clientY"; - -/// The JavaScript event name for the window resize event. -pub(crate) const RAYTRACE_EVENT_RESIZE: &str = "resize"; - -/// The JavaScript property name for the device pixel ratio on `Window`. -pub(crate) const RAYTRACE_EVENT_PROPERTY_DEVICE_PIXEL_RATIO: &str = "devicePixelRatio"; +/// Every ladder step is a multiple of this pair, so the backing store +/// keeps the exact 4:3 ratio the `c_raytrace_canvas_fullscreen` +/// `object-fit: contain` letterbox contract expects. +pub(crate) const RAYTRACE_WIDTH: f64 = 320.0; -/// Delay in milliseconds before the raytrace loop's first `requestAnimationFrame` -/// callback is scheduled, allowing the canvas element to mount before the -/// first frame attempts to acquire a 2D context. -pub(crate) const RAYTRACE_LOOP_START_DELAY_MILLIS: i32 = 360; +/// Logical height of the RayTrace Canvas 2D tab's internal framebuffer at +/// render scale 1.0. +pub(crate) const RAYTRACE_HEIGHT: f64 = 240.0; /// The render-scale ladder for the Canvas 2D adaptive-resolution path. /// /// The backing buffer is sized `320 * scale` by `240 * scale`, so every -/// step keeps the exact 4:3 aspect ratio required by the -/// `c_raytrace_canvas_fullscreen` `object-fit: contain` letterbox -/// contract. All steps produce integer dimensions: 1280x960, 960x720, -/// 800x600, 640x480, 560x420, 480x360, 400x300, 320x240, 240x180, -/// 160x120, 120x90, 80x60. The loop starts at index 7 (scale 1.0) so -/// weak clients never start heavy; the controller climbs toward 4.0 -/// only when the frame-time budget allows, so the backing buffer can -/// approach the physical canvas size on strong hardware instead of -/// relying on the browser's smooth upscale. +/// step keeps a 4:3 frame: 1280x960, 960x720, 800x600, 640x480, 560x420, +/// 480x360, 400x300, 320x240, 240x180, 160x120, 120x90, 80x60. The loop +/// starts at index 7 (scale 1.0) so weak clients never start heavy, and +/// the controller only climbs toward 4.0 after sustained fast frames prove +/// the budget allows it. pub(crate) const RAYTRACE_RENDER_SCALES: [f64; 12] = [ 4.0, 3.0, 2.5, 2.0, 1.75, 1.5, 1.25, 1.0, 0.75, 0.5, 0.375, 0.25, ]; -/// Exponential-moving-average blend factor for the per-frame CPU render -/// time measurement that drives adaptive resolution. +/// The index into [`RAYTRACE_RENDER_SCALES`] the Canvas 2D loop starts +/// at. +pub(crate) const RAYTRACE_RENDER_SCALE_START_INDEX: usize = 7; + +/// Exponential-moving-average blend factor for the measured per-frame +/// render time that drives adaptive resolution. pub(crate) const RAYTRACE_ADAPT_EMA_ALPHA: f64 = 0.1; -/// CPU frame time in milliseconds above which the adaptive-resolution +/// Measured render time in milliseconds above which the adaptive /// controller steps the render scale down (115% of the 60 FPS budget). pub(crate) const RAYTRACE_ADAPT_SLOW_FRAME_MILLIS: f64 = 16.67 * 1.15; -/// CPU frame time in milliseconds below which the adaptive-resolution +/// Measured render time in milliseconds below which the adaptive /// controller steps the render scale up one rung (75% of the 60 FPS /// budget). pub(crate) const RAYTRACE_ADAPT_FAST_FRAME_MILLIS: f64 = 16.67 * 0.75; -/// CPU frame time in milliseconds below which the adaptive-resolution -/// controller steps the render scale up two rungs at once (45% of the -/// 60 FPS budget), skipping intermediate rungs when the headroom is -/// obvious. +/// Measured render time in milliseconds below which the adaptive +/// controller steps the render scale up two rungs at once (45% of the 60 +/// FPS budget), skipping intermediate rungs when the headroom is obvious. pub(crate) const RAYTRACE_ADAPT_VERY_FAST_FRAME_MILLIS: f64 = 16.67 * 0.45; /// Number of consecutive slow frames required before stepping the render @@ -134,693 +113,102 @@ pub(crate) const RAYTRACE_ADAPT_SLOW_FRAMES: u32 = 30; /// below [`RAYTRACE_ADAPT_VERY_FAST_FRAME_MILLIS`] for the same span). pub(crate) const RAYTRACE_ADAPT_FAST_FRAMES: u32 = 45; -/// The CSS selector used to query the RayTrace WebGL canvas element. -pub(crate) const RAYTRACE_WEBGL_CANVAS_SELECTOR: &str = "#raytrace-webgl-canvas"; - -/// The CSS selector for the RayTrace WebGL loading overlay canvas. -pub(crate) const RAYTRACE_WEBGL_LOADING_CANVAS_SELECTOR: &str = "#raytrace-webgl-loading-canvas"; - -/// The CSS selector used to query the RayTrace WebGPU canvas element. -pub(crate) const RAYTRACE_WEBGPU_CANVAS_SELECTOR: &str = "#raytrace-webgpu-canvas"; - -/// The CSS selector for the RayTrace WebGPU loading overlay canvas. -pub(crate) const RAYTRACE_WEBGPU_LOADING_CANVAS_SELECTOR: &str = "#raytrace-webgpu-loading-canvas"; +/// Supersampling grid resolution per axis used by every RayTrace tab. +/// +/// `2` traces four rays per pixel, matching the 2x2 supersampling the +/// demo has always used and keeping the three backends on one sampling +/// grid. It sits inside the engine's accepted `1..=4` range, so +/// `RayTraceSettings::validate` accepts it unchanged. +pub(crate) const RAYTRACE_SAMPLES_PER_AXIS: u32 = 2; -/// The number of `vec4` slots in the GPU uniform block shared by the -/// WebGL and WebGPU raytrace shaders: orbit eye, camera forward, right, -/// up, sun direction, sun color, ambient, resolution, sun screen -/// position, and lamp anchor (the floor-point under the sun where the -/// lit pool converges). -pub(crate) const RAYTRACE_GPU_UNIFORM_VEC4_COUNT: usize = 10; +/// The secondary bounce limit used by every RayTrace tab. +/// +/// `4` matches the engine default and the bounce count the demo has +/// always advertised, and sits inside the engine's accepted `0..=16` +/// range. +pub(crate) const RAYTRACE_MAX_BOUNCES: u32 = 4; -/// The y-coordinate of the floor's top surface. +/// The orbit yaw speed in radians per second for auto-rotation. /// -/// The lamp-anchor uniform copies the sun's x/z onto this constant so -/// the shader knows where on the AABB the lit pool converges. Mirrors -/// the `GROUND_MAX` `y` value baked into the WebGL and WebGPU shaders. -pub(crate) const GROUND_Y_TOP_FLOOR_LAMP: f64 = -0.5; +/// Mirrors the 3D game page's constant so the two orbit demos feel the +/// same when both are visible in the sidebar. +pub(crate) const RAYTRACE_AUTO_YAW_SPEED: f64 = 0.5; -/// Distance from the scene origin at which the visible sun sphere is -/// placed along the sun direction. +/// The minimum angle in radians between the camera pitch and +/- pi/2. /// -/// The sun sphere's world position is always -/// `raytrace_sun_direction(yaw) * RAYTRACE_SUN_DISTANCE`, so the glowing -/// disk the user sees and the vector the shading math integrates share a -/// single source of truth and can never drift apart. All three backends -/// derive the sphere centre from the same sun-direction value: the CPU -/// path multiplies it in `build_raytrace_scene`, and the two shaders -/// multiply the `sun_dir` uniform by `SUN_DISTANCE`. -pub(crate) const RAYTRACE_SUN_DISTANCE: f64 = 8.0; - -/// Radius of the visible sun sphere in world units. +/// Wider than a pure gimbal guard on purpose. The engine derives the +/// camera basis as `forward x up`, and a forward vector very close to +/// vertical makes that cross product collapse toward zero; past the +/// point where it reaches zero the engine rejects the frame outright as +/// a degenerate basis. An 0.12 rad floor keeps the cross product's +/// horizontal component at 0.12 or more, which is the smallest value that +/// both stays numerically far from the degenerate case and still lets the +/// user look nearly straight down at the scene. +pub(crate) const RAYTRACE_PITCH_CLAMP: f64 = 0.12; + +/// The sensitivity multiplier applied to pointer drag deltas before they +/// are folded into orbit angles. /// -/// Mirrors `SUN_RADIUS` in the WebGL and WebGPU shaders. -pub(crate) const RAYTRACE_SUN_RADIUS: f64 = 0.5; +/// Matches the 3D game page's pointer handlers so the two demos feel +/// identical in drag responsiveness. +pub(crate) const RAYTRACE_DRAG_SENSITIVITY: f64 = 0.01; -/// The number of precomputed occluder bounding spheres uploaded each -/// frame to the GPU shaders (matches the engine's `shadow_points`). -pub(crate) const RAYTRACE_GPU_SPHERE_PACK_COUNT: usize = 4; +/// The radius of the orbit sphere the camera sits on. +pub(crate) const RAYTRACE_CAMERA_DISTANCE: f64 = 8.0; -/// The GLSL ES 3.00 vertex shader source for the RayTrace WebGL demo. -/// -/// Attribute-less fullscreen triangle generated from `gl_VertexID`, the -/// same pattern the 3D game page uses for its WebGL programs. -pub(crate) const RAYTRACE_WEBGL_VERTEX_SHADER: &str = r#"#version 300 es - -void main() { - vec2 positions[3] = vec2[3]( - vec2(-1.0, -1.0), - vec2(3.0, -1.0), - vec2(-1.0, 3.0) - ); - gl_Position = vec4(positions[gl_VertexID], 0.0, 1.0); -} -"#; - -/// The GLSL ES 3.00 fragment shader source for the RayTrace WebGL demo. +/// The x-coordinate of the orbit sphere's centre (the scene's look-at +/// target). +pub(crate) const RAYTRACE_CAMERA_LOOK_AT_X: f64 = 0.0; + +/// The y-coordinate of the orbit sphere's centre (the scene's look-at +/// target). +pub(crate) const RAYTRACE_CAMERA_LOOK_AT_Y: f64 = 0.4; + +/// The z-coordinate of the orbit sphere's centre (the scene's look-at +/// target). +pub(crate) const RAYTRACE_CAMERA_LOOK_AT_Z: f64 = 0.0; + +/// The fixed viewer position the demo's specular lobes are evaluated +/// against. /// -/// Traces the exact same scene as the Canvas 2D software path: a ground -/// AABB, a mirror sphere, and an emissive sphere lit by one directional -/// sun. The math mirrors `RayTraceScene::trace` and -/// `LightingUniforms::shade` from euv-engine term for term, including -/// the directional-light-no-shadow and eye-distance-falloff quirks. -/// Per pixel 2x2 sub-samples are averaged (matching the CPU SSAA) and -/// a `1/2.2` gamma curve is applied. The camera basis, sun direction, -/// ambient, and canvas resolution arrive in the `u_params` `vec4` -/// array; the NDC is aspect-corrected from the resolution so the scene -/// never stretches at any canvas size. -pub(crate) const RAYTRACE_WEBGL_FRAGMENT_SHADER: &str = r#"#version 300 es - -precision highp float; - -uniform vec4 u_params[10]; -uniform vec4 u_sphere_packs[4]; - -out vec4 out_color; - -// Mirrors the engine's pub(crate) RAYTRACE_DEFAULT_MAX_BOUNCES. -const int MAX_BOUNCES = 4; -// Mirrors the engine's RAYTRACE_DEFAULT_T_MIN / RAYTRACE_DEFAULT_T_MAX. -const float T_MIN = 0.001; -const float T_MAX = 1000.0; -// Mirrors the engine's math EPSILON. -const float EPS = 1e-6; -// The fixed eye used by `LightingUniforms::shade` on the CPU path; the -// orbiting camera only moves the ray origin so specular highlights stay -// stable while orbiting. -const vec3 SHADE_EYE = vec3(0.0, 0.8, 3.5); -const vec3 GROUND_MIN = vec3(-5.0, -0.6, -5.0); -const vec3 GROUND_MAX = vec3(5.0, -0.5, 5.0); -const vec3 MIRROR_CENTER = vec3(0.0, 0.4, 0.0); -const float MIRROR_RADIUS = 0.9; -const vec3 EMISSIVE_CENTER = vec3(1.6, 0.6, -1.4); -const float EMISSIVE_RADIUS = 0.45; -// Sun sphere: positioned along the sun direction at distance -// SUN_DISTANCE so the visible disk and the floor's lit pool share a -// single source of truth. Replaces the previous yaw=0-only placement, -// which kept the sphere pinned to one corner regardless of the -// orbiting sun direction. -const float SUN_DISTANCE = 8.0; -const float SUN_RADIUS = 0.5; - -vec3 material_albedo(int index) { - if (index == 0) { return vec3(0.30, 0.32, 0.36); } - if (index == 1) { return vec3(0.05, 0.05, 0.06); } - return vec3(0.0, 0.0, 0.0); -} - -float material_specular(int index) { - if (index == 0) { return 0.30; } - if (index == 1) { return 1.0; } - return 0.0; -} - -float material_shininess(int index) { - if (index == 0) { return 24.0; } - if (index == 1) { return 64.0; } - return 0.0; -} - -vec3 material_emissive(int index) { - if (index == 2) { return vec3(1.0, 0.45, 0.10); } - if (index == 3) { return vec3(1.00, 0.95, 0.85); } - return vec3(0.0, 0.0, 0.0); -} - -// Mirrors engine `ray_sphere_intersect`; returns -1.0 on miss. -float sphere_t(vec3 origin, vec3 dir, vec3 center, float radius, out vec3 normal) { - vec3 oc = origin - center; - float b = dot(oc, dir); - float c = dot(oc, oc) - radius * radius; - float disc = b * b - c; - if (disc < 0.0) { return -1.0; } - float sq = sqrt(disc); - float t1 = -b - sq; - float t2 = -b + sq; - float t = t1; - if (t1 < 0.0) { - t = t2; - } - if (t < 0.0) { return -1.0; } - normal = normalize(origin + dir * t - center); - return t; -} - -// Mirrors engine `ray_aabb_intersect` (slab method + max-axis normal); -// returns -1.0 on miss. -float aabb_t(vec3 origin, vec3 dir, vec3 bmin, vec3 bmax, out vec3 normal) { - vec3 inv_dir = 1.0 / dir; - vec3 t1 = (bmin - origin) * inv_dir; - vec3 t2 = (bmax - origin) * inv_dir; - vec3 tmin = min(t1, t2); - vec3 tmax = max(t1, t2); - float t_near = max(max(tmin.x, tmin.y), tmin.z); - float t_far = min(min(tmax.x, tmax.y), tmax.z); - if (t_near > t_far || t_far < 0.0) { return -1.0; } - vec3 hit = origin + dir * t_near; - vec3 center = (bmin + bmax) * 0.5; - vec3 extent = (bmax - bmin) * 0.5; - vec3 d = hit - center; - vec3 a = abs(d) / max(extent, vec3(EPS)); - if (a.x >= a.y && a.x >= a.z) { - normal = vec3(sign(d.x), 0.0, 0.0); - } else if (a.y >= a.z) { - normal = vec3(0.0, sign(d.y), 0.0); - } else { - normal = vec3(0.0, 0.0, sign(d.z)); - } - return t_near; -} - -// Mirrors engine `closest_hit_indexed` over the four scene occluders: -// 0 = ground AABB, 1 = mirror sphere, 2 = emissive sphere, 3 = sun -// sphere. Returns -1 on miss. Ties keep the earliest occluder, -// matching the engine. -int closest_hit_index( - vec3 origin, - vec3 dir, - float t_min, - float t_max, - vec3 sun_position, - out float best_t, - out vec3 best_pos, - out vec3 best_normal -) { - int best_index = -1; - best_t = 0.0; - best_pos = vec3(0.0); - best_normal = vec3(0.0, 1.0, 0.0); - vec3 candidate_normal = vec3(0.0); - float t = aabb_t(origin, dir, GROUND_MIN, GROUND_MAX, candidate_normal); - if (t >= t_min && t <= t_max) { - best_index = 0; - best_t = t; - best_pos = origin + dir * t; - best_normal = candidate_normal; - } - t = sphere_t(origin, dir, MIRROR_CENTER, MIRROR_RADIUS, candidate_normal); - if (t >= t_min && t <= t_max && (best_index < 0 || t < best_t)) { - best_index = 1; - best_t = t; - best_pos = origin + dir * t; - best_normal = candidate_normal; - } - t = sphere_t(origin, dir, EMISSIVE_CENTER, EMISSIVE_RADIUS, candidate_normal); - if (t >= t_min && t <= t_max && (best_index < 0 || t < best_t)) { - best_index = 2; - best_t = t; - best_pos = origin + dir * t; - best_normal = candidate_normal; - } - t = sphere_t(origin, dir, sun_position, SUN_RADIUS, candidate_normal); - if (t >= t_min && t <= t_max && (best_index < 0 || t < best_t)) { - best_index = 3; - best_t = t; - best_pos = origin + dir * t; - best_normal = candidate_normal; - } - return best_index; -} - -// Mirrors engine `LightingUniforms::shade` for the positional sun: -// ambient + Lambert diffuse + Phong specular + emissive. The sun is -// point at `sun_position` so the engine's `soft_shadow_factor` is used -// in `trace` to evaluate occlusion; shadows attenuate the diffuse and -// specular contributions. The falloff is 0 (set by `build_raytrace_lighting`) -// so the sun reads as a distant source and the floor stays uniformly -// lit wherever the shadow rays reach it. -vec3 shade(vec3 position, vec3 normal, int index, vec3 sun_position, vec3 sun_color, vec3 ambient, float shadow) { - vec3 to_eye = SHADE_EYE - position; - float view_dist = length(to_eye); - vec3 view_dir = vec3(0.0); - if (view_dist > EPS) { - view_dir = to_eye / view_dist; - } - vec3 to_light = sun_position - position; - float light_dist = length(to_light); - vec3 light_dir = vec3(0.0); - if (light_dist > EPS) { - light_dir = to_light / light_dist; - } - vec3 albedo = material_albedo(index); - float cos_term = max(dot(normal, light_dir), 0.0); - vec3 diffuse = sun_color * (cos_term * shadow) * albedo; - float specular = material_specular(index); - vec3 spec = vec3(0.0); - if (specular > 0.0) { - vec3 reflect_dir = normalize(light_dir - normal * (2.0 * dot(light_dir, normal))); - float spec_factor = pow(max(dot(reflect_dir, view_dir), 0.0), material_shininess(index)); - spec = sun_color * (spec_factor * specular * shadow); - } - return ambient + diffuse + spec + material_emissive(index); -} - -// Mirrors engine `soft_shadow_factor` - returns 1.0 if no occluder -// blocks the path from `origin` toward `light_pos`, otherwise 0.0. -// The bounding spheres `(center, radius)` are precomputed once per -// frame in `RayTraceScene::new` and packed into the `u_sphere_packs` -// uniform array below; this stays binary (no penumbra sampling) to -// mirror the engine exactly. -float occluder_shadow_sphere(vec3 origin, vec3 light_pos, vec3 center, float radius, float dist_to_light) { - vec3 to_light = light_pos - origin; - vec3 dir = vec3(0.0); - if (dist_to_light > EPS) { - dir = to_light / dist_to_light; - } - vec3 oc = origin - center; - float b = dot(oc, dir); - float c = dot(oc, oc) - radius * radius; - float disc = b * b - c; - if (disc < 0.0) { return 1.0; } - float sq = sqrt(disc); - float t1 = -b - sq; - float t2 = -b + sq; - float t = t1; - if (t1 < 0.0) { - t = t2; - } - if (t < 0.0 || t >= dist_to_light - EPS) { return 1.0; } - return 0.0; -} - -// Four precomputed occluder bounding spheres packed as `vec4(center.xyz, -// radius)`. Matches the engine's `shadow_points` exactly. Declared once -// at the top of the shader alongside `u_params`. - -float soft_shadow_factor(vec3 origin, vec3 light_pos) { - float dist_to_light = length(light_pos - origin); - float shadow = 1.0; - shadow *= occluder_shadow_sphere(origin, light_pos, u_sphere_packs[0].xyz, u_sphere_packs[0].w, dist_to_light); - shadow *= occluder_shadow_sphere(origin, light_pos, u_sphere_packs[1].xyz, u_sphere_packs[1].w, dist_to_light); - shadow *= occluder_shadow_sphere(origin, light_pos, u_sphere_packs[2].xyz, u_sphere_packs[2].w, dist_to_light); - shadow *= occluder_shadow_sphere(origin, light_pos, u_sphere_packs[3].xyz, u_sphere_packs[3].w, dist_to_light); - return shadow; -} - -// Mirrors engine `trace_bounces` - throughput-weighted iterative -// reflection with at most MAX_BOUNCES bounces; a miss adds the ambient -// color scaled by the current throughput. -vec3 trace(vec3 origin, vec3 dir, vec3 sun_position, vec3 sun_color, vec3 ambient) { - vec3 color = vec3(0.0); - float throughput = 1.0; - int depth = 0; - for (int bounce = 0; bounce <= MAX_BOUNCES; bounce++) { - float hit_t = 0.0; - vec3 hit_pos = vec3(0.0); - vec3 hit_normal = vec3(0.0, 1.0, 0.0); - int index = closest_hit_index(origin, dir, T_MIN, T_MAX, sun_position, hit_t, hit_pos, hit_normal); - if (index < 0) { - color += ambient * throughput; - break; - } - float shadow = soft_shadow_factor(hit_pos, sun_position); - color += shade(hit_pos, hit_normal, index, sun_position, sun_color, ambient, shadow) * throughput; - float spec = material_specular(index); - if (depth >= MAX_BOUNCES || spec <= EPS) { break; } - throughput *= spec; - dir = dir - hit_normal * (2.0 * dot(dir, hit_normal)); - origin = hit_pos; - depth += 1; - } - return color; -} - -void main() { - vec3 eye = u_params[0].xyz; - vec3 forward = u_params[1].xyz; - vec3 right = u_params[2].xyz; - vec3 up = u_params[3].xyz; - vec3 sun_dir = u_params[4].xyz; - vec3 sun_color = u_params[5].rgb; - vec3 ambient = u_params[6].rgb; - vec2 resolution = u_params[7].xy; - float aspect = resolution.x / resolution.y; - float base_x = floor(gl_FragCoord.x); - // gl_FragCoord is bottom-up; the CPU path scans top-down, which - // flips ndc_y. Sampling bottom-up directly yields the same set of - // sub-sample NDC values. - float base_y = floor(gl_FragCoord.y); - // The visible sun sphere and the shadow-ray target both sit at the - // sun direction MIRRORED ABOUT THE GROUND PLANE (only y is negated), - // scaled to SUN_DISTANCE. `sun_dir` points down (negative y), so - // using it unmirrored would bury the sun under the floor while the - // floor's Phong lobe — whose peak is `reflect(sun_dir, +y)`, i.e. - // the same y-negation — stayed above it. Mirroring here is what - // keeps the glowing disk and the floor's lit pool on the same side. - // Mirrors the CPU path's `raytrace_sun_position`. - vec3 sun_position = vec3(sun_dir.x, -sun_dir.y, sun_dir.z) * SUN_DISTANCE; - vec3 acc = vec3(0.0); - for (int sy = 0; sy < 2; sy++) { - for (int sx = 0; sx < 2; sx++) { - float px = base_x + 0.25 + float(sx) * 0.5; - float py = base_y + 0.25 + float(sy) * 0.5; - float ndc_x = (px / resolution.x) * 2.0 - 1.0; - float ndc_y = (py / resolution.y) * 2.0 - 1.0; - vec3 dir = normalize(forward + right * (ndc_x * aspect) + up * ndc_y); - acc += trace(eye, dir, sun_position, sun_color, ambient); - } - } - vec3 linear = acc * 0.25; - vec3 gamma = pow(clamp(linear, vec3(0.0), vec3(1.0)), vec3(1.0 / 2.2)); - out_color = vec4(gamma, 1.0); -} -"#; - -/// The WGSL shader source for the RayTrace WebGPU demo. +/// The light set is built once and reused for the lifetime of the loop so +/// no frame allocates a light list, which means the shading eye is fixed +/// as well: the demo traces exactly what it packs, and the specular +/// highlight on the mirror sphere reads as a highlight for this viewer +/// rather than tracking the orbiting eye. +pub(crate) const RAYTRACE_SHADING_EYE_X: f64 = 0.0; + +/// The y component of [`RAYTRACE_SHADING_EYE_X`]'s viewer position. +pub(crate) const RAYTRACE_SHADING_EYE_Y: f64 = 1.2; + +/// The z component of [`RAYTRACE_SHADING_EYE_X`]'s viewer position. +pub(crate) const RAYTRACE_SHADING_EYE_Z: f64 = 4.2; + +/// The JavaScript property name for the touch list `touches` on a +/// `TouchEvent`. +pub(crate) const RAYTRACE_EVENT_PROPERTY_TOUCHES: &str = "touches"; + +/// The JavaScript property name for the client X coordinate on a `Touch` +/// object. +pub(crate) const RAYTRACE_EVENT_PROPERTY_CLIENT_X: &str = "clientX"; + +/// The JavaScript property name for the client Y coordinate on a `Touch` +/// object. +pub(crate) const RAYTRACE_EVENT_PROPERTY_CLIENT_Y: &str = "clientY"; + +/// The JavaScript event name for the window resize event. +pub(crate) const RAYTRACE_EVENT_RESIZE: &str = "resize"; + +/// The JavaScript property name for the device pixel ratio on `Window`. +pub(crate) const RAYTRACE_EVENT_PROPERTY_DEVICE_PIXEL_RATIO: &str = "devicePixelRatio"; + +/// Delay in milliseconds before a loop's first `requestAnimationFrame` +/// callback is scheduled, allowing the canvas element to mount before the +/// first frame attempts to acquire a backend. +pub(crate) const RAYTRACE_LOOP_START_DELAY_MILLIS: i32 = 360; + +/// Debounce window in milliseconds applied to window resize events before +/// they mark the GPU-backed loops' backing store dirty. /// -/// Mirrors [`RAYTRACE_WEBGL_FRAGMENT_SHADER`]: the same hardcoded -/// ground-AABB / mirror-sphere / emissive-sphere scene, the same -/// `trace_bounces` + `LightingUniforms::shade` math, the same 2x2 SSAA -/// and `1/2.2` gamma. The fullscreen triangle is generated from -/// `@builtin(vertex_index)` and the per-frame camera / sun / ambient / -/// resolution data arrives in a single 8-`vec4` uniform buffer at -/// `@group(0) @binding(0)`. -pub(crate) const RAYTRACE_WEBGPU_SHADER: &str = r#" -struct SceneUniforms { - camera_eye: vec4, - camera_forward: vec4, - camera_right: vec4, - camera_up: vec4, - sun_dir: vec4, - sun_color: vec4, - ambient: vec4, - resolution: vec4, -}; - -struct SpherePack { - center_radius: vec4, -}; - -@group(0) @binding(0) var u_scene: SceneUniforms; -@group(0) @binding(1) var u_sphere_packs: array; - -fn sphere_pack_center(idx: i32) -> vec3 { - return u_sphere_packs[idx].center_radius.xyz; -} - -fn sphere_pack_radius(idx: i32) -> f32 { - return u_sphere_packs[idx].center_radius.w; -} - -// Mirrors the engine's pub(crate) RAYTRACE_DEFAULT_MAX_BOUNCES. -const MAX_BOUNCES: i32 = 4; -// Mirrors the engine's RAYTRACE_DEFAULT_T_MIN / RAYTRACE_DEFAULT_T_MAX. -const T_MIN: f32 = 0.001; -const T_MAX: f32 = 1000.0; -// Mirrors the engine's math EPSILON. -const EPS: f32 = 1e-6; -// The fixed eye used by `LightingUniforms::shade` on the CPU path. -const SHADE_EYE = vec3(0.0, 0.8, 3.5); -const GROUND_MIN = vec3(-5.0, -0.6, -5.0); -const GROUND_MAX = vec3(5.0, -0.5, 5.0); -const MIRROR_CENTER = vec3(0.0, 0.4, 0.0); -const MIRROR_RADIUS: f32 = 0.9; -const EMISSIVE_CENTER = vec3(1.6, 0.6, -1.4); -const EMISSIVE_RADIUS: f32 = 0.45; -// Sun sphere: positioned along the sun direction at SUN_DISTANCE so the -// visible disk and the floor's lit pool share a single source of truth. -// Replaces the previous yaw=0-only placement. -const SUN_DISTANCE: f32 = 8.0; -const SUN_RADIUS: f32 = 0.5; - -struct HitResult { - t: f32, - index: i32, - position: vec3, - normal: vec3, -}; - -fn material_albedo(index: i32) -> vec3 { - if index == 0 { return vec3(0.30, 0.32, 0.36); } - if index == 1 { return vec3(0.05, 0.05, 0.06); } - return vec3(0.0, 0.0, 0.0); -} - -fn material_specular(index: i32) -> f32 { - if index == 0 { return 0.30; } - if index == 1 { return 1.0; } - return 0.0; -} - -fn material_shininess(index: i32) -> f32 { - if index == 0 { return 24.0; } - if index == 1 { return 64.0; } - return 0.0; -} - -fn material_emissive(index: i32) -> vec3 { - if index == 2 { return vec3(1.0, 0.45, 0.10); } - if index == 3 { return vec3(1.00, 0.95, 0.85); } - return vec3(0.0, 0.0, 0.0); -} - -// Mirrors engine `ray_sphere_intersect`; returns -1.0 on miss. -fn sphere_t(origin: vec3, dir: vec3, center: vec3, radius: f32, normal: ptr>) -> f32 { - let oc = origin - center; - let b = dot(oc, dir); - let c = dot(oc, oc) - radius * radius; - let disc = b * b - c; - if disc < 0.0 { return -1.0; } - let sq = sqrt(disc); - let t1 = -b - sq; - let t2 = -b + sq; - var t = t1; - if t1 < 0.0 { - t = t2; - } - if t < 0.0 { return -1.0; } - *normal = normalize(origin + dir * t - center); - return t; -} - -// Mirrors engine `ray_aabb_intersect` (slab method + max-axis normal); -// returns -1.0 on miss. -fn aabb_t(origin: vec3, dir: vec3, bmin: vec3, bmax: vec3, normal: ptr>) -> f32 { - let inv_dir = 1.0 / dir; - let t1 = (bmin - origin) * inv_dir; - let t2 = (bmax - origin) * inv_dir; - let tmin = min(t1, t2); - let tmax = max(t1, t2); - let t_near = max(max(tmin.x, tmin.y), tmin.z); - let t_far = min(min(tmax.x, tmax.y), tmax.z); - if t_near > t_far || t_far < 0.0 { return -1.0; } - let hit = origin + dir * t_near; - let center = (bmin + bmax) * 0.5; - let extent = (bmax - bmin) * 0.5; - let d = hit - center; - let a = abs(d) / max(extent, vec3(EPS)); - if a.x >= a.y && a.x >= a.z { - *normal = vec3(sign(d.x), 0.0, 0.0); - } else if a.y >= a.z { - *normal = vec3(0.0, sign(d.y), 0.0); - } else { - *normal = vec3(0.0, 0.0, sign(d.z)); - } - return t_near; -} - -// Mirrors engine `closest_hit_indexed` over the four scene occluders: -// 0 = ground AABB, 1 = mirror sphere, 2 = emissive sphere, 3 = sun -// sphere. `index` is -1 on miss. Ties keep the earliest occluder, -// matching the engine. -fn closest_hit_index(origin: vec3, dir: vec3, sun_dir: vec3) -> HitResult { - var best: HitResult; - best.t = 0.0; - best.index = -1; - best.position = vec3(0.0); - best.normal = vec3(0.0, 1.0, 0.0); - var candidate_normal = vec3(0.0); - var t = aabb_t(origin, dir, GROUND_MIN, GROUND_MAX, &candidate_normal); - if t >= T_MIN && t <= T_MAX { - best.index = 0; - best.t = t; - best.position = origin + dir * t; - best.normal = candidate_normal; - } - t = sphere_t(origin, dir, MIRROR_CENTER, MIRROR_RADIUS, &candidate_normal); - if t >= T_MIN && t <= T_MAX && (best.index < 0 || t < best.t) { - best.index = 1; - best.t = t; - best.position = origin + dir * t; - best.normal = candidate_normal; - } - t = sphere_t(origin, dir, EMISSIVE_CENTER, EMISSIVE_RADIUS, &candidate_normal); - if t >= T_MIN && t <= T_MAX && (best.index < 0 || t < best.t) { - best.index = 2; - best.t = t; - best.position = origin + dir * t; - best.normal = candidate_normal; - } - t = sphere_t( - origin, - dir, - vec3(sun_dir.x, -sun_dir.y, sun_dir.z) * SUN_DISTANCE, - SUN_RADIUS, - &candidate_normal, - ); - if t >= T_MIN && t <= T_MAX && (best.index < 0 || t < best.t) { - best.index = 3; - best.t = t; - best.position = origin + dir * t; - best.normal = candidate_normal; - } - return best; -} - -// Mirrors engine `LightingUniforms::shade` for the positional sun: -// ambient + Lambert diffuse + Phong specular + emissive. The sun is a -// point light at `sun_position` so the engine's `soft_shadow_factor` -// evaluates occlusion and the resulting `shadow` factor attenuates -// both diffuse and specular contributions. Falloff is 0 (set by -// `build_raytrace_lighting`) so the sun reads as a distant source and -// the floor stays uniformly lit wherever the shadow rays reach it. -fn shade(position: vec3, normal: vec3, index: i32, sun_position: vec3, shadow: f32) -> vec3 { - let to_eye = SHADE_EYE - position; - let view_dist = length(to_eye); - var view_dir = vec3(0.0); - if view_dist > EPS { - view_dir = to_eye / view_dist; - } - let sun_color = u_scene.sun_color.rgb; - let to_light = sun_position - position; - let light_dist = length(to_light); - var light_dir = vec3(0.0); - if light_dist > EPS { - light_dir = to_light / light_dist; - } - let albedo = material_albedo(index); - let cos_term = max(dot(normal, light_dir), 0.0); - let diffuse = sun_color * (cos_term * shadow * albedo); - let specular = material_specular(index); - var spec = vec3(0.0); - if specular > 0.0 { - let reflect_dir = normalize(light_dir - normal * (2.0 * dot(light_dir, normal))); - let spec_factor = pow(max(dot(reflect_dir, view_dir), 0.0), material_shininess(index)); - spec = sun_color * (spec_factor * specular * shadow); - } - return u_scene.ambient.rgb + diffuse + spec + material_emissive(index); -} - -// Mirrors engine `soft_shadow_factor` - 1.0 if no occluder blocks the -// path from `origin` toward `light_pos`, otherwise 0.0. The bounding -// spheres `(center, radius)` are precomputed once per frame in -// `RayTraceScene::new` and packed into `u_sphere_packs`; this stays -// binary (no penumbra sampling) to mirror the engine exactly. -fn occluder_shadow_sphere(origin: vec3, light_pos: vec3, center: vec3, radius: f32, dist_to_light: f32) -> f32 { - var dir = vec3(0.0); - if dist_to_light > EPS { - dir = (light_pos - origin) / dist_to_light; - } - let oc = origin - center; - let b = dot(oc, dir); - let c = dot(oc, oc) - radius * radius; - let disc = b * b - c; - if disc < 0.0 { return 1.0; } - let sq = sqrt(disc); - let t1 = -b - sq; - let t2 = -b + sq; - var t = t1; - if t1 < 0.0 { - t = t2; - } - if t < 0.0 || t >= dist_to_light - EPS { return 1.0; } - return 0.0; -} - -fn soft_shadow_factor(origin: vec3, light_pos: vec3) -> f32 { - let dist_to_light = length(light_pos - origin); - var shadow: f32 = 1.0; - shadow *= occluder_shadow_sphere(origin, light_pos, sphere_pack_center(0), sphere_pack_radius(0), dist_to_light); - shadow *= occluder_shadow_sphere(origin, light_pos, sphere_pack_center(1), sphere_pack_radius(1), dist_to_light); - shadow *= occluder_shadow_sphere(origin, light_pos, sphere_pack_center(2), sphere_pack_radius(2), dist_to_light); - shadow *= occluder_shadow_sphere(origin, light_pos, sphere_pack_center(3), sphere_pack_radius(3), dist_to_light); - return shadow; -} - -// Mirrors engine `trace_bounces` - throughput-weighted iterative -// reflection with at most MAX_BOUNCES bounces; a miss adds the ambient -// color scaled by the current throughput. -fn trace(origin_arg: vec3, dir_arg: vec3, sun_dir: vec3) -> vec3 { - var color = vec3(0.0); - var throughput = 1.0; - var origin = origin_arg; - var dir = dir_arg; - var depth = 0; - let sun_position = vec3(sun_dir.x, -sun_dir.y, sun_dir.z) * SUN_DISTANCE; - for (var bounce = 0; bounce <= MAX_BOUNCES; bounce++) { - let hit = closest_hit_index(origin, dir, sun_dir); - if hit.index < 0 { - color += u_scene.ambient.rgb * throughput; - break; - } - let shadow = soft_shadow_factor(hit.position, sun_position); - color += shade(hit.position, hit.normal, hit.index, sun_position, shadow) * throughput; - let spec = material_specular(hit.index); - if depth >= MAX_BOUNCES || spec <= EPS { break; } - throughput *= spec; - dir = dir - hit.normal * (2.0 * dot(dir, hit.normal)); - origin = hit.position; - depth += 1; - } - return color; -} - -@vertex -fn vs_main(@builtin(vertex_index) vi: u32) -> @builtin(position) vec4 { - var positions = array, 3> ( - vec2(-1.0, -1.0), - vec2(3.0, -1.0), - vec2(-1.0, 3.0), - ); - return vec4(positions[vi], 0.0, 1.0); -} - -@fragment -fn fs_main(@builtin(position) frag_pos: vec4) -> @location(0) vec4 { - let eye = u_scene.camera_eye.xyz; - let forward = u_scene.camera_forward.xyz; - let right = u_scene.camera_right.xyz; - let up = u_scene.camera_up.xyz; - let sun_dir = u_scene.sun_dir.xyz; - let resolution = u_scene.resolution.xy; - let aspect = resolution.x / resolution.y; - // WebGPU fragment positions are top-left origin, matching the CPU - // path's top-down scanline order. - let base_x = floor(frag_pos.x); - let base_y = floor(frag_pos.y); - var acc = vec3(0.0); - for (var sy = 0; sy < 2; sy++) { - for (var sx = 0; sx < 2; sx++) { - let px = base_x + 0.25 + f32(sx) * 0.5; - let py = base_y + 0.25 + f32(sy) * 0.5; - let ndc_x = (px / resolution.x) * 2.0 - 1.0; - let ndc_y = 1.0 - (py / resolution.y) * 2.0; - let dir = normalize(forward + right * (ndc_x * aspect) + up * ndc_y); - acc += trace(eye, dir, sun_dir); - } - } - let linear = acc * 0.25; - let gamma = pow(clamp(linear, vec3(0.0), vec3(1.0)), vec3(1.0 / 2.2)); - return vec4(gamma, 1.0); -} -"#; +/// Mirrors `GAME_3D_RESIZE_DEBOUNCE_MILLIS`. +pub(crate) const RAYTRACE_RESIZE_DEBOUNCE_MILLIS: i32 = 100; diff --git a/example/src/page/raytrace/hook/fn.rs b/example/src/page/raytrace/hook/fn.rs index 80e22e87..eb7d847e 100644 --- a/example/src/page/raytrace/hook/fn.rs +++ b/example/src/page/raytrace/hook/fn.rs @@ -1,5 +1,4 @@ use super::*; - /// Creates the RayTrace Canvas 2D tab reactive state. /// /// # Returns @@ -12,19 +11,21 @@ pub(crate) fn use_raytrace_state() -> UseRayTrace { loop_started: App::use_signal(|| false), auto_rotate: App::use_signal(|| true), render_scale: App::use_signal(|| 1.0), - // `loaded` is `false` while the SSAA wrapper is being acquired - // and the first warmup frame is being traced. The view renders - // a `c_game_loading_overlay` canvas on top of the raytrace - // canvas for exactly this window so the user sees a centered - // "Initializing..." line instead of a half-rendered frame. The - // loop flips it to `true` after the first successful render, - // matching the WebGL / WebGPU tabs' loading UX. + // `loaded` is `false` while the backend is being acquired and the + // first frame is being rendered. The view paints a + // `c_game_loading_overlay` canvas on top of the raytrace canvas for + // exactly this window, so the user sees a centered "Initializing..." + // line instead of a half-rendered frame. The loop flips it to + // `true` after the first successful render, matching the WebGL and + // WebGPU tabs' loading UX. loaded: App::use_signal(|| false), - // `active` mirrors the WebGL / WebGPU tabs: `true` once the - // SSAA canvas is acquired and frames are being traced. The view - // does not read it directly today, but exposing it here keeps - // the per-tab status surface uniform. + // `active` mirrors the WebGL / WebGPU tabs: `true` only once a + // frame has actually been rendered through the engine backend. + // The view reads it, so a rejected frame cannot present itself as a + // healthy renderer. active: App::use_signal(|| false), + error_detail: App::use_signal(String::new), + init_error_code: App::use_signal(|| ""), } } @@ -42,6 +43,7 @@ pub(crate) fn use_raytrace_webgl_state() -> UseRayTraceWebGl { active: App::use_signal(|| false), loop_started: App::use_signal(|| false), init_error_code: App::use_signal(|| ""), + error_detail: App::use_signal(String::new), } } @@ -59,6 +61,7 @@ pub(crate) fn use_raytrace_webgpu_state() -> UseRayTraceWebGpu { active: App::use_signal(|| false), loop_started: App::use_signal(|| false), init_error_code: App::use_signal(|| ""), + error_detail: App::use_signal(String::new), } } @@ -82,12 +85,11 @@ pub(crate) fn use_raytrace_fullscreen_state() -> UseRayTraceFullscreen { } /// Returns `true` when no element matches the canvas selector, meaning the -/// page or tab was navigated away from and the game loop should stop. +/// page or tab was navigated away from and the loop should stop. /// /// Hook-context cleanups (`App::use_cleanup`) only run on match-arm /// switches, not on router navigation, so RAF loops additionally guard on -/// canvas presence to avoid simulating and rendering against a detached -/// canvas forever. +/// canvas presence to avoid rendering against a detached canvas forever. /// /// # Arguments /// @@ -107,10 +109,10 @@ fn raytrace_canvas_detached(canvas_selector: &str) -> bool { /// `getBoundingClientRect`. /// /// The rect reflects the target CSS size immediately (unlike -/// `clientWidth`/`clientHeight`, which track the backing store in Chrome -/// and would create a feedback loop if read every frame). Page-scoped -/// copy of the game-page helper: the name stays private so it cannot -/// collide with the `game_2d` / `game_3d` `read_canvas_size` re-exports. +/// `clientWidth`/`clientHeight`, which track the backing store in Chrome and +/// would create a feedback loop if read every frame). Page-scoped copy of +/// the game-page helper: the name stays private so it cannot collide with +/// the `game_2d` / `game_3d` `read_canvas_size` re-exports. /// /// # Arguments /// @@ -118,7 +120,7 @@ fn raytrace_canvas_detached(canvas_selector: &str) -> bool { /// /// # Returns /// -/// - `Option<(f64, f64)>` - The (width, height) in CSS pixels. +/// - `Option<(f64, f64)>` - The `(width, height)` in CSS pixels. fn read_raytrace_canvas_size(canvas_selector: &str) -> Option<(f64, f64)> { let window_value: Window = window()?; let document_value: Document = window_value.document()?; @@ -131,616 +133,842 @@ fn read_raytrace_canvas_size(canvas_selector: &str) -> Option<(f64, f64)> { Some((rect.width(), rect.height())) } -/// Acquires the Canvas 2D context for the RayTrace demo canvas wrapped in -/// a `SsaaCanvas` so all ray-traced geometry is rendered into an -/// offscreen 2x backing store and downscaled onto the display canvas -/// with `imageSmoothingEnabled = true` / `imageSmoothingQuality = "high"`. -/// -/// Without SSAA the ray-traced framebuffer is uploaded via -/// `put_image_data` straight to the visible canvas, so the browser -/// composites it at its native (4:3 ladder) resolution into the 3:2 -/// inline CSS box. The bilinear upscale on a hard-edged framebuffer -/// produces visible ball-edge and ground-edge aliasing, exactly the -/// "all elements not anti-aliased" report the user filed. -/// -/// The 2x SSAA wrapper produces a smoother downscale pass on every -/// frame: the 2D context's high-quality image smoothing averages -/// adjacent framebuffer pixels into the display canvas's backing -/// store, smoothing the otherwise-jagged edges of the ray-traced -/// spheres, the ground AABB outline, and the emissive sphere's -/// terminator. +/// Resolves a RayTrace canvas element from its CSS selector. +/// +/// # Arguments +/// +/// - `&str` - The CSS selector for the canvas element. /// /// # Returns /// -/// - `Option<(HtmlCanvasElement, SsaaCanvas)>` - The display canvas -/// plus the SSAA wrapper, or `None` if the canvas element was not -/// found or a 2D context could not be acquired. -fn acquire_raytrace_ssaa_canvas() -> Option<(HtmlCanvasElement, SsaaCanvas)> { +/// - `Option` - The canvas, or `None` when it is absent +/// or no longer in the document. +fn query_raytrace_canvas(canvas_selector: &str) -> Option { let window_value: Window = window()?; - let is_mobile: bool = window_value - .inner_width() - .ok() - .and_then(|value: JsValue| value.as_f64()) - .is_some_and(|width: f64| width < 768.0); - let scale_factor: f64 = if is_mobile { 1.0 } else { 2.0 }; - let (canvas_width, canvas_height): (f64, f64) = - read_raytrace_canvas_size(RAYTRACE_CANVAS_SELECTOR)?; - let ssaa_canvas: SsaaCanvas = SsaaCanvas::from_selector_with_scale( - RAYTRACE_CANVAS_SELECTOR, - canvas_width, - canvas_height, - scale_factor, - )?; let document_value: Document = window_value.document()?; let element: Element = document_value - .query_selector(RAYTRACE_CANVAS_SELECTOR) + .query_selector(canvas_selector) .ok() .flatten()?; - let display_canvas: HtmlCanvasElement = element.unchecked_into(); - Some((display_canvas, ssaa_canvas)) + Some(element.unchecked_into()) } -/// Builds the raytracing scene used by the RayTrace demo for a given -/// orbit yaw. +/// Builds the shared ray-tracing scene used by all three RayTrace tabs. +/// +/// The scene is deliberately **static**: no light rotates with the camera, +/// and no occluder is placed along a light's own direction. Every backend +/// therefore uploads it exactly once, at `RayTraceRenderer::init`, and no +/// steady-state frame pays a scene repack. It mixes all three primitive +/// kinds the engine supports so the shared-data contract is visible rather +/// than claimed: +/// +/// - **ground AABB** — a wide Lambert plate that catches the directional +/// sun's shadow. +/// - **mirror sphere** — a Phong material whose specular term drives the +/// secondary bounces, so `max_bounces` is doing visible work. +/// - **emissive sphere** — a standalone `Material::emissive` body. It is +/// an *object that emits*, not a lamp representation: the new shading +/// path casts real shadow rays, so putting a point light at its centre +/// would have the sphere occlude its own light and black out the whole +/// scene. The demo therefore keeps it purely emissive and places every +/// actual light where nothing blocks it. +/// - **mirror triangle** — a two-sided triangle, present so the scene +/// carries a primitive kind that is neither sphere nor box. /// -/// Four occluders: a mirror sphere in the centre (Phong specular -/// material drives the reflection), an emissive sphere in the back -/// (acts as the only secondary light source visible to bounced rays), -/// the visible sun sphere, and a ground AABB below the spheres. +/// # Returns /// -/// The sun sphere is placed at `raytrace_sun_direction(yaw) * -/// RAYTRACE_SUN_DISTANCE`, i.e. **along** the direction the shading math -/// receives, so the glowing disk on screen is the actual light position -/// rather than a decoration pinned to an unrelated spot. Because the sun -/// direction rotates with the yaw, the scene has to be rebuilt per frame -/// for the sun sphere and the floor's lit pool to stay locked together. +/// - `RayTraceScene` - The shared demo scene. +pub(crate) fn build_raytrace_scene() -> RayTraceScene { + let ground_material: Material = Material::lambert(Vector3D::new(0.62, 0.63, 0.66)); + let ground: Occluder = Occluder::aabb( + Vector3D::new(-6.0, -0.62, -6.0), + Vector3D::new(6.0, -0.5, 6.0), + ground_material, + ); + let mirror_material: Material = Material::phong(Vector3D::new(0.86, 0.87, 0.9), 1.0, 96.0); + let mirror: Occluder = Occluder::sphere(Vector3D::new(-0.9, 0.35, 0.2), 0.85, mirror_material); + let emissive_material: Material = Material::emissive(Vector3D::new(1.0, 0.42, 0.12)); + let emissive: Occluder = + Occluder::sphere(Vector3D::new(1.75, 0.55, -1.35), 0.42, emissive_material); + let panel_material: Material = Material::phong(Vector3D::new(0.24, 0.46, 0.62), 0.9, 48.0); + let panel: Occluder = Occluder::triangle( + Vector3D::new(-2.7, -0.5, -2.3), + Vector3D::new(0.4, 2.55, -3.6), + Vector3D::new(2.3, -0.5, -1.15), + panel_material, + ); + let occluders: Vec = vec![ground, mirror, emissive, panel]; + RayTraceScene::new(occluders) +} + +/// Builds the shared, static lighting for the RayTrace scene. /// -/// # Arguments +/// Every light sits in open space with a clear line to the geometry it is +/// meant to light, which matters more here than in the previous demo: +/// shading now casts exact shadow rays through the same BVH the primary +/// rays traverse, so a light hidden behind (or inside) an occluder casts +/// real, hard shadows instead of the old bounding-sphere approximation. /// -/// - `f64` - The current camera yaw in radians. +/// - **directional sun** — points down and toward the scene from open sky. +/// Directional lights have no position, so nothing can occlude them. +/// - **point fill** — hangs above and behind the mirror sphere, in free +/// space, giving the shadowed side of the scene a visible gradient. +/// - **spot rim** — aims at the emissive sphere from outside the scene to +/// make the cone term observable. +/// +/// The set is built once and cloned by reference thereafter: because the +/// lights never move, no frame rebuilds it, so the packed light records +/// are byte-identical for the life of the loop. /// /// # Returns /// -/// - `(Vec, Vector3D)` - The scene occluders and the eye position. -fn build_raytrace_scene(yaw: f64) -> (Vec, Vector3D) { - let ground_min: Vector3D = Vector3D::new(-5.0, -0.6, -5.0); - let ground_max: Vector3D = Vector3D::new(5.0, -0.5, 5.0); - let ground_material: Material = Material::phong(Vector3D::new(0.30, 0.32, 0.36), 0.30, 24.0); - let ground: Occluder = Occluder::aabb(ground_min, ground_max, ground_material); - let mirror_material: Material = Material::phong(Vector3D::new(0.05, 0.05, 0.06), 1.0, 64.0); - let mirror: Occluder = Occluder::sphere(Vector3D::new(0.0, 0.4, 0.0), 0.9, mirror_material); - let emissive_material: Material = Material::emissive(Vector3D::new(1.0, 0.45, 0.10)); - let emissive: Occluder = - Occluder::sphere(Vector3D::new(1.6, 0.6, -1.4), 0.45, emissive_material); - let sun_material: Material = Material::emissive(Vector3D::new(1.00, 0.95, 0.85)); - let sun: Occluder = Occluder::sphere( - raytrace_sun_position(yaw), - RAYTRACE_SUN_RADIUS, - sun_material, +/// - `LightingUniforms` - The static demo lighting. +pub(crate) fn build_raytrace_lighting() -> LightingUniforms { + let eye: Vector3D = Vector3D::new( + RAYTRACE_SHADING_EYE_X, + RAYTRACE_SHADING_EYE_Y, + RAYTRACE_SHADING_EYE_Z, + ); + let sun: Light = Light::new_directional( + Vector3D::new(0.42, -0.86, 0.3).normalized(), + Vector3D::new(1.0, 0.95, 0.84), + ); + let fill: Light = Light::new_point( + Vector3D::new(-2.6, 2.9, 2.4), + Vector3D::new(0.42, 0.52, 0.78), + 2.6, ); - let occluders: Vec = vec![ground, mirror, emissive, sun]; - let eye: Vector3D = Vector3D::new(0.0, 0.8, 3.5); - (occluders, eye) + let rim: Light = Light::new_spot( + Vector3D::new(3.1, 2.35, 1.75), + (Vector3D::new(1.75, 0.55, -1.35) - Vector3D::new(3.1, 2.35, 1.75)).normalized(), + Vector3D::new(0.78, 0.86, 1.0), + 3.2, + 0.62, + ); + let mut lights: LightingUniforms = LightingUniforms::with_eye(eye); + lights.set_ambient(Vector3D::new(0.09, 0.1, 0.14)); + lights.add_light(sun); + lights.add_light(fill); + lights.add_light(rim); + lights } -/// Computes the normalized sun direction for the current orbit yaw. +/// Builds the `RayTraceSettings` every tab renders with. /// -/// Shared by the CPU lighting builder, the visible sun sphere placement, -/// and the GPU uniform packer so all three backends agree on the light. +/// # Returns +/// +/// - `RayTraceSettings` - The shared sampling and bounce settings. +pub(crate) fn build_raytrace_settings() -> RayTraceSettings { + RayTraceSettings::with_quality(RAYTRACE_SAMPLES_PER_AXIS, RAYTRACE_MAX_BOUNCES) +} + +/// Computes the eye position for the given orbit yaw / pitch angles. +/// +/// Mirrors the orbit-to-eye conversion the 3D game page uses: the camera +/// sits on a sphere of radius [`RAYTRACE_CAMERA_DISTANCE`] centred on the +/// scene's look-at target, so dragging horizontally rotates around the +/// scene and dragging vertically tilts up / down. /// /// # Arguments /// -/// - `f64` - The current camera yaw in radians. +/// - `f64` - The orbit yaw in radians. +/// - `f64` - The orbit pitch in radians. /// /// # Returns /// -/// - `Vector3D` - The unit sun direction. -fn raytrace_sun_direction(yaw: f64) -> Vector3D { - Vector3D::new(-yaw.cos(), -0.5, -yaw.sin()).normalized() +/// - `Vector3D` - The eye position in world space. +pub(crate) fn compute_eye_position(yaw: f64, pitch: f64) -> Vector3D { + let cos_pitch: f64 = pitch.cos(); + let distance: f64 = RAYTRACE_CAMERA_DISTANCE; + Vector3D::new( + RAYTRACE_CAMERA_LOOK_AT_X + distance * yaw.sin() * cos_pitch, + RAYTRACE_CAMERA_LOOK_AT_Y + distance * pitch.sin(), + RAYTRACE_CAMERA_LOOK_AT_Z + distance * yaw.cos() * cos_pitch, + ) } -/// Computes the world-space position of the visible sun sphere for the -/// current orbit yaw. -/// -/// This is the mirror image of the sun direction about the ground plane, -/// scaled out to [`RAYTRACE_SUN_DISTANCE`], and it is the reason the -/// floor's bright pool and the glowing disk finally agree. -/// -/// The ground is the only surface whose lit pattern carries a positional -/// cue. `LightingUniforms::shade` hardcodes the shadow factor to `1.0` -/// for directional lights, and a directional light's Lambert term is -/// constant across a flat plane, so neither shadows nor diffuse can -/// place a pool anywhere. What the user actually sees on the floor is -/// the Phong lobe from `compute_phong`, whose peak lies along -/// `light_dir - normal * 2 * dot(light_dir, normal)`; for the ground's -/// `+y` normal that reduces to negating the direction's `y` component. -/// The pool therefore sits on the `(x, z)` side the sun direction points -/// from, lifted above the plane. -/// -/// The previous code placed the sphere at `sun_direction * -8.0`, which -/// negates `x` and `z` as well and put the disk on the diametrically -/// opposite side of the scene from the pool it was supposed to explain — -/// exactly the "floor light does not match the light source" mismatch. -/// Mirroring only `y` keeps the disk on the pool's side and above the -/// floor, so the highlight reads as the sun's reflection. +/// Builds the orbit camera for a given yaw / pitch and frame size. +/// +/// The engine's `pack_raytrace_frame` derives the view basis from +/// `position` / `target` / `up` and rejects a degenerate one, so the demo +/// supplies all three and never a precomputed basis of its own. The +/// viewport is the *frame* size, not the CSS size: the engine derives the +/// aspect from the width and height it is asked to render, which is what +/// keeps the three backends from disagreeing about aspect while they +/// resize independently. /// /// # Arguments /// -/// - `f64` - The current camera yaw in radians. +/// - `f64` - The orbit yaw in radians. +/// - `f64` - The orbit pitch in radians. +/// - `u32` - The frame width in pixels. +/// - `u32` - The frame height in pixels. /// /// # Returns /// -/// - `Vector3D` - The sun sphere centre in world space. -fn raytrace_sun_position(yaw: f64) -> Vector3D { - let direction: Vector3D = raytrace_sun_direction(yaw); - Vector3D::new(direction.get_x(), -direction.get_y(), direction.get_z()) - .scaled(RAYTRACE_SUN_DISTANCE) +/// - `Camera3D` - The orbit camera for this frame. +pub(crate) fn build_raytrace_camera(yaw: f64, pitch: f64, width: u32, height: u32) -> Camera3D { + Camera3D::create( + compute_eye_position(yaw, pitch), + Vector3D::new( + RAYTRACE_CAMERA_LOOK_AT_X, + RAYTRACE_CAMERA_LOOK_AT_Y, + RAYTRACE_CAMERA_LOOK_AT_Z, + ), + f64::from(width), + f64::from(height), + ) } -/// Builds the per-frame lighting uniforms for the raytrace scene. +/// Reads the backing-store width of a tab's canvas. +/// +/// # Arguments +/// +/// - `&str` - The CSS selector of the tab's canvas. /// -/// The single directional sun rotates with the current yaw so the lit -/// side of the spheres tracks the orbiting camera: as the user drags -/// the camera the highlight smoothly slides off the visible side of -/// the mirror. Ambient and the specular eye stay constant. +/// # Returns /// -/// The light stays directional on purpose. A point light at the sun's -/// position would be shadow-tested through `soft_shadow_factor`, which -/// approximates every occluder by a bounding sphere — the ground AABB's -/// is 7.07 units in radius and contains every floor point, so each floor -/// point would occlude itself and the whole ground would render black. +/// - `u32` - The backing width in pixels, or `1` when the canvas cannot be +/// found. A `1`-pixel frame is rejected by the engine, so a vanished +/// canvas surfaces as a real error rather than a silently empty frame. +pub(crate) fn raytrace_canvas_backing_width(canvas_selector: &str) -> u32 { + query_raytrace_canvas(canvas_selector) + .map(|canvas: HtmlCanvasElement| canvas.width()) + .unwrap_or(1) +} + +/// Reads the backing-store height of a tab's canvas. /// /// # Arguments /// -/// - `Vector3D` - The eye position used for specular calculations. -/// - `f64` - The current camera yaw in radians. +/// - `&str` - The CSS selector of the tab's canvas. /// /// # Returns /// -/// - `LightingUniforms` - The per-frame lighting. -fn build_raytrace_lighting(eye: Vector3D, yaw: f64) -> LightingUniforms { - let sun: Light = - Light::new_directional(raytrace_sun_direction(yaw), Vector3D::new(1.0, 0.95, 0.85)); - let mut lights: LightingUniforms = LightingUniforms::with_eye(eye); - lights.set_ambient(Vector3D::new(0.10, 0.10, 0.14)); - lights.add_light(sun); - lights +/// - `u32` - The backing height in pixels, or `1` when the canvas cannot be +/// found. +pub(crate) fn raytrace_canvas_backing_height(canvas_selector: &str) -> u32 { + query_raytrace_canvas(canvas_selector) + .map(|canvas: HtmlCanvasElement| canvas.height()) + .unwrap_or(1) } -/// Clamps an `0..=infinity` linear color channel into the `0..=1` -/// range used by the sRGB gamma curve. +/// Computes the integer backing buffer dimensions for a render-scale +/// ladder step. /// /// # Arguments /// -/// - `f64` - The linear color channel value. +/// - `f64` - The render scale from [`RAYTRACE_RENDER_SCALES`]. /// /// # Returns /// -/// - `f64` - The clamped value in `[0, 1]`. -fn clamp_unit(value: f64) -> f64 { - value.clamp(0.0, 1.0) +/// - `(u32, u32)` - The `(width, height)` in pixels (always 4:3). +pub(crate) fn raytrace_scaled_dimensions(scale: f64) -> (u32, u32) { + let width: u32 = (RAYTRACE_WIDTH * scale).round().max(1.0) as u32; + let height: u32 = (RAYTRACE_HEIGHT * scale).round().max(1.0) as u32; + (width, height) } -/// Packs a linear `0..=1` color channel into an sRGB byte, applying the -/// shared `1/2.2` gamma curve. +/// Extracts a human-readable detail string out of a rejected engine call. +/// +/// A `JsValue` error from the backend is usually a string, but a thrown +/// object or a number is equally legal, so anything that is not a string +/// falls back to a fixed placeholder instead of being rendered as +/// `undefined` on the page. /// /// # Arguments /// -/// - `f64` - The linear color channel value. +/// - `&JsValue` - The rejected error value. /// /// # Returns /// -/// - `u8` - The gamma-corrected 8-bit channel value. -fn gamma_byte(value: f64) -> u8 { - (clamp_unit(value).powf(1.0 / 2.2) * 255.0).round() as u8 +/// - `String` - The detail text to log and display. +pub(crate) fn raytrace_error_detail(error: &JsValue) -> String { + error + .as_string() + .unwrap_or_else(|| RAYTRACE_ERROR_DETAIL_UNKNOWN.to_string()) } -/// Computes the camera basis (forward, right, up_true) for a given -/// yaw / pitch orbit position. +/// Records a rejected engine call on the tab's reactive state. /// -/// Mirrors the spherical-to-Cartesian conversion used by the 3D game -/// page so the two demos produce visually equivalent camera paths. The -/// `look_at` target is fixed at the scene origin's mid-height; only the -/// eye position changes with yaw/pitch. +/// Every failure path routes through here, which is what keeps the +/// status banner honest: `active` is only ever set after a frame has +/// actually rendered, and it is cleared again the moment a frame starts +/// failing, so a dead renderer can never keep reporting itself as live. /// /// # Arguments /// -/// - `Vector3D` - The eye position. -/// - `f64` - The orbit yaw in radians. -/// - `f64` - The orbit pitch in radians. +/// - `Signal` - The tab's `active` flag. +/// - `Signal` - The tab's error detail signal. +/// - `Signal<&'static str>` - The tab's stable error code signal. +/// - `&'static str` - The stable error code for this call site. +/// - `&str` - A short label naming the call that failed. +/// - `&JsValue` - The rejected error value. +pub(crate) fn raytrace_record_error( + active: Signal, + error_detail: Signal, + init_error_code: Signal<&'static str>, + code: &'static str, + call: &str, + error: &JsValue, +) { + let detail: String = raytrace_error_detail(error); + Console::error(format!( + "{RAYTRACE_LOG_PREFIX} {call} failed [{code}]: {detail}" + )); + error_detail.set(detail); + init_error_code.set(code); + active.set(false); +} + +/// Clears the tab's error state after a successful frame. /// -/// # Returns +/// # Arguments /// -/// - `(Vector3D, Vector3D, Vector3D)` - The (forward, right, up_true) basis. -fn build_camera_basis(eye: Vector3D, yaw: f64, pitch: f64) -> (Vector3D, Vector3D, Vector3D) { - let look_at: Vector3D = Vector3D::new(0.0, 0.4, 0.0); - let up: Vector3D = Vector3D::new(0.0, 1.0, 0.0); - let forward: Vector3D = (look_at - eye).normalized(); - let _: f64 = yaw; - let _: f64 = pitch; - let right: Vector3D = forward.cross(up).normalized(); - let up_true: Vector3D = right.cross(forward).normalized(); - (forward, right, up_true) +/// - `Signal` - The tab's `active` flag. +/// - `Signal` - The tab's error detail signal. +/// - `Signal<&'static str>` - The tab's stable error code signal. +pub(crate) fn raytrace_clear_error( + active: Signal, + error_detail: Signal, + init_error_code: Signal<&'static str>, +) { + error_detail.set(String::new()); + init_error_code.set(""); + active.set(true); } -/// Computes the eye position for the given orbit yaw / pitch angles. +/// Schedules a one-shot `setTimeout` whose Rust closure is owned rather +/// than leaked with `Closure::forget`. /// -/// Mirrors the orbit-to-eye conversion in -/// `create_orbit_camera` from the 3D game page: the camera sits on a -/// sphere of radius `RAYTRACE_CAMERA_DISTANCE` centred on the scene's -/// look-at target, so dragging horizontally rotates around the scene -/// and dragging vertically tilts up / down. +/// Every entry is a `(timer handle, closure)` pair so +/// [`raytrace_release_pending_timers`] can cancel the browser timer +/// *before* dropping the closure: a timer whose Rust environment has +/// already been deallocated throws `closure invoked recursively or after +/// being dropped` at the JavaScript boundary, before any Rust code could +/// observe the teardown flag. The pairing, not the leak, is what makes +/// the release order safe. /// /// # Arguments /// -/// - `f64` - The orbit yaw in radians. -/// - `f64` - The orbit pitch in radians. +/// - `RayTracePendingTimers` - The owner list +/// shared with the loop's teardown. +/// - `i32` - The delay in milliseconds before the body runs. +/// - `F` - The body to run once the timer fires. +fn raytrace_schedule_one_shot(pending_timers: RayTracePendingTimers, millis: i32, body: F) +where + F: FnMut() + 'static, +{ + let timer_closure: Closure = Closure::wrap(Box::new(body)); + let Some(window_value): Option = window() else { + return; + }; + let timer_callback: Function = timer_closure.as_ref().unchecked_ref::().clone(); + match window_value + .set_timeout_with_callback_and_timeout_and_arguments_0(&timer_callback, millis) + { + Ok(timer_id) => { + let mut pending_guard: RayTracePendingTimerGuard = match pending_timers.try_borrow_mut() + { + Ok(guard) => guard, + Err(_contended) => return, + }; + pending_guard.push((timer_id, timer_closure)); + } + Err(_reason) => {} + } +} + +/// Cancels every pending one-shot timer of a loop and then drops the +/// Rust closures behind them. /// -/// # Returns +/// The ids are collected before the owner list is emptied, and each +/// `clear_timeout` runs while no borrow of the owner list is alive, so +/// no re-entrant call can observe a contended `RefCell`. /// -/// - `Vector3D` - The eye position in world space. -fn compute_eye_position(yaw: f64, pitch: f64) -> Vector3D { - let cos_pitch: f64 = pitch.cos(); - let distance: f64 = RAYTRACE_CAMERA_DISTANCE; - Vector3D::new( - distance * yaw.sin() * cos_pitch, - RAYTRACE_CAMERA_LOOK_AT_Y + distance * pitch.sin(), - RAYTRACE_CAMERA_LOOK_AT_Z + distance * yaw.cos() * cos_pitch, - ) +/// # Arguments +/// +/// - `RayTracePendingTimers` - The owner list +/// shared with the loop. +fn raytrace_release_pending_timers(pending_timers: RayTracePendingTimers) { + let timer_ids: Vec = pending_timers + .borrow() + .iter() + .map(|entry: &(i32, Closure)| entry.0) + .collect(); + for timer_id in timer_ids { + let Some(window_value): Option = window() else { + return; + }; + window_value.clear_timeout_with_handle(timer_id); + } + let mut pending_guard: RayTracePendingTimerGuard = match pending_timers.try_borrow_mut() { + Ok(guard) => guard, + Err(_contended) => return, + }; + pending_guard.clear(); } -/// Computes the integer backing buffer dimensions for a render-scale -/// ladder step. +/// Sets a tab's `loaded` signal after a short delay so the loading +/// overlay is actually painted before it is removed. +/// +/// `init` resolves well inside a single frame for a warm backend, and most +/// browsers collapse a mount + unmount that happens within one tick into a +/// single paint, so the overlay would never be seen. Delaying the flip +/// guarantees every tab shows the same "Initializing..." window. /// /// # Arguments /// -/// - `f64` - The render scale from [`RAYTRACE_RENDER_SCALES`]. +/// - `RayTracePendingTimers` - The owner list +/// the timer is registered in. +/// - `Signal` - The tab's `loaded` signal to set. +/// - `i32` - The delay in milliseconds before setting the signal. +fn raytrace_set_loaded_delayed( + pending_timers: RayTracePendingTimers, + loaded: Signal, + millis: i32, +) { + raytrace_schedule_one_shot(pending_timers, millis, move || { + loaded.set(true); + }); +} + +/// Registers the debounced window-resize listener shared by the GPU +/// backed loops. +/// +/// The listener body is registered through `App::use_window_event`, so +/// the framework owns it for exactly as long as the hook context lives +/// and unregisters it on the same cleanup that runs on a tab switch. +/// The `Closure` behind the JavaScript callback the body schedules is +/// therefore handed back as an owner cell for the caller to move into +/// its own cleanup, rather than being leaked with `Closure::forget`. +/// +/// It is deliberately *not* released by [`raytrace_release_loop`]: a +/// handler that is still registered can still be invoked, and dropping +/// its callback from under it would turn the next window resize into a +/// thrown exception at the JavaScript boundary. The `cancelled` flag is +/// what makes that surviving handler inert instead. +/// +/// # Arguments +/// +/// - `Rc>` - The resize-dirty flag set after the debounce fires. +/// - `Rc>>` - The pending debounce timer handle. +/// - `Rc>` - The loop's cancellation flag, checked before the +/// debounce timer is armed. /// /// # Returns /// -/// - `(u32, u32)` - The `(width, height)` in pixels (always 4:3). -fn raytrace_scaled_dimensions(scale: f64) -> (u32, u32) { - let width: u32 = (RAYTRACE_WIDTH * scale).round() as u32; - let height: u32 = (RAYTRACE_HEIGHT * scale).round() as u32; - (width, height) +/// - `RayTraceDebounceOwner` - The cell owning the +/// debounce closure. +fn raytrace_register_resize_debounce( + resize_dirty: Rc>, + resize_timer: Rc>>, + cancelled: Rc>, +) -> RayTraceDebounceOwner { + let debounce_owner: RayTraceDebounceOwner = Rc::new(RefCell::new(None)); + let resize_dirty_for_event: Rc> = resize_dirty; + let resize_timer_for_event: Rc>> = resize_timer; + let cancelled_for_event: Rc> = cancelled; + let debounce_closure: Closure = Closure::wrap(Box::new(move || { + resize_dirty_for_event.set(true); + })); + let debounce_callback: Function = debounce_closure + .as_ref() + .unchecked_ref::() + .clone(); + let Some(resize_window): Option = window() else { + *debounce_owner.borrow_mut() = Some(debounce_closure); + return debounce_owner; + }; + App::use_window_event(RAYTRACE_EVENT_RESIZE, move || { + if cancelled_for_event.get() { + return; + } + let old_timer: Option = resize_timer_for_event.get(); + if let Some(timer_id) = old_timer { + let Some(clear_window): Option = window() else { + return; + }; + clear_window.clear_timeout_with_handle(timer_id); + } + let new_timer: i32 = resize_window + .set_timeout_with_callback_and_timeout_and_arguments_0( + &debounce_callback, + RAYTRACE_RESIZE_DEBOUNCE_MILLIS, + ) + .unwrap_or_default(); + resize_timer_for_event.set(Some(new_timer)); + }); + *debounce_owner.borrow_mut() = Some(debounce_closure); + debounce_owner +} + +/// Reads the current device pixel ratio, rejecting nonsense values. +/// +/// # Returns +/// +/// - `f64` - The device pixel ratio, or `1.0` when it cannot be read or +/// is not a finite value of at least 1. +pub(crate) fn read_raytrace_dpr() -> f64 { + let Some(window_value): Option = window() else { + return 1.0; + }; + Reflect::get( + window_value.as_ref(), + &JsValue::from_str(RAYTRACE_EVENT_PROPERTY_DEVICE_PIXEL_RATIO), + ) + .ok() + .and_then(|value: JsValue| value.as_f64()) + .filter(|value: &f64| value.is_finite() && *value >= 1.0) + .unwrap_or(1.0) } -/// Renders one full frame of the RayTrace demo into the RGBA byte -/// framebuffer. +/// Resizes a tab's engine backend to the canvas's physical size. +/// +/// The renderer is the **single** commit point for the size: it commits +/// its own dimensions, the canvas backing store, and its pixel buffer +/// only after the backend has accepted the request, so a rejected size +/// leaves the canvas showing the previous, still valid, surface. This +/// wrapper therefore never touches `canvas.width` / `canvas.height` +/// itself — writing them first would break that transaction, because the +/// canvas would already be showing a surface the renderer never adopted +/// when `resize` returns `Err`. /// -/// Builds the camera basis once, then for every pixel in the backing -/// buffer computes a primary `Ray`, traces it through the precomputed -/// `RayTraceScene` (zero heap allocation per ray), and packs the -/// resulting linear color into four RGBA bytes with the shared `1/2.2` -/// gamma curve. Lighting is rebuilt per frame by the caller so the -/// directional sun tracks the camera orbit. +/// The call is skipped entirely when the target already matches the +/// backing store: a steady frame therefore performs no DOM write, no +/// GL / GPU viewport touch, and no reallocation. /// /// # Arguments /// -/// - `&mut [u8]` - The RGBA framebuffer (length `width * height * 4`). -/// - `u32` - The framebuffer width in pixels. -/// - `u32` - The framebuffer height in pixels. -/// - `&RayTraceScene` - The scene with precomputed shadow data. -/// - `&LightingUniforms` - The per-frame lighting. -/// - `f64` - The orbit yaw in radians. -/// - `f64` - The orbit pitch in radians. -fn render_raytrace_frame( - buffer: &mut [u8], - width: u32, - height: u32, - scene: &RayTraceScene, - lights: &LightingUniforms, - yaw: f64, - pitch: f64, -) { - let width_f64: f64 = f64::from(width); - let height_f64: f64 = f64::from(height); - let eye: Vector3D = compute_eye_position(yaw, pitch); - let (forward, right, up_true): (Vector3D, Vector3D, Vector3D) = - build_camera_basis(eye, yaw, pitch); - let aspect: f64 = width_f64 / height_f64; - let focal: f64 = 1.0; - let inv_width: f64 = 1.0 / width_f64; - let inv_height: f64 = 1.0 / height_f64; - let sub_offsets: [(f64, f64); 4] = [(0.25, 0.25), (0.75, 0.25), (0.25, 0.75), (0.75, 0.75)]; - let mut index: usize = 0; - for y in 0..height { - for x in 0..width { - let mut acc_r: f64 = 0.0; - let mut acc_g: f64 = 0.0; - let mut acc_b: f64 = 0.0; - for (dx_off, dy_off) in sub_offsets { - let px: f64 = f64::from(x) + dx_off; - let py: f64 = f64::from(y) + dy_off; - let ndc_x: f64 = (px * inv_width) * 2.0 - 1.0; - let ndc_y: f64 = 1.0 - (py * inv_height) * 2.0; - let dir: Vector3D = - (forward.scaled(focal) + right.scaled(ndc_x * aspect) + up_true.scaled(ndc_y)) - .normalized(); - let ray: Ray = Ray::new(eye, dir); - let color: Vector3D = scene.trace(ray, lights); - acc_r += color.get_x(); - acc_g += color.get_y(); - acc_b += color.get_z(); - } - buffer[index] = gamma_byte(acc_r * 0.25); - buffer[index + 1] = gamma_byte(acc_g * 0.25); - buffer[index + 2] = gamma_byte(acc_b * 0.25); - buffer[index + 3] = 255; - index += 4; +/// - `&mut RayTraceRenderer` - The engine backend. +/// - `&str` - The CSS selector of the tab's canvas. +/// - `Signal` - The tab's `active` flag. +/// - `Signal` - The tab's error detail signal. +/// - `Signal<&'static str>` - The tab's stable error code signal. +/// +/// # Returns +/// +/// - `bool` - Whether the backend now matches the canvas. +pub(crate) fn raytrace_resize_backend_to_canvas( + renderer: &mut RayTraceRenderer, + canvas_selector: &str, + active: Signal, + error_detail: Signal, + init_error_code: Signal<&'static str>, +) -> bool { + let Some(canvas): Option = query_raytrace_canvas(canvas_selector) else { + return false; + }; + let backing_width: u32 = canvas.width(); + let backing_height: u32 = canvas.height(); + let dpr: f64 = read_raytrace_dpr(); + let (css_width, css_height): (f64, f64) = + read_raytrace_canvas_size(canvas_selector).unwrap_or((0.0, 0.0)); + if css_width <= 0.0 || css_height <= 0.0 { + return backing_width > 0 && backing_height > 0; + } + let target_width: u32 = (css_width * dpr).round() as u32; + let target_height: u32 = (css_height * dpr).round() as u32; + if target_width == 0 || target_height == 0 { + return backing_width > 0 && backing_height > 0; + } + if backing_width == target_width && backing_height == target_height { + return true; + } + match renderer.resize(target_width, target_height) { + Ok(()) => { + raytrace_clear_error(active, error_detail, init_error_code); + true + } + Err(error) => { + raytrace_record_error( + active, + error_detail, + init_error_code, + RAYTRACE_ERROR_CODE_RESIZE, + "resize", + &error, + ); + false } } } -/// Paints the RayTrace CSS-framebackground (the 4:3 ladder region) onto -/// the supplied `SsaaCanvas` offscreen context and presents the -/// high-quality downscale to the display canvas. -/// -/// Three concerns drive the layout: -/// -/// 1. **The 4:3 ladder is smaller than the offscreen context.** The -/// `SsaaCanvas` is sized to the raytrace canvas's CSS box -/// (`c_game_canvas_wrapper`, 3:2 inline) which is wider than the -/// 4:3 ladder framebuffer. A `put_image_data` upload to `(0, 0)` -/// would leave the right 12% of the offscreen backing black. The -/// visual cost is a permanently-letterboxed right edge inside the -/// SSAA buffer, which the downscale faithfully composites. -/// -/// 2. **`put_image_data` is a raw upload** with no image-smoothing, -/// so the ladder framebuffer's hard edges survive the 2x SSAA -/// downscale only when the ladder size matches the offscreen -/// physical size exactly. They do not (the ladder is 320x240 at -/// scale 1.0, the offscreen is `css_w * 2 * dpr` x -/// `css_h * 2 * dpr`). -/// -/// 3. **`draw_image` is the only path that respects -/// `imageSmoothingEnabled`.** We upload the ladder framebuffer -/// to a fresh 4:3 `HtmlCanvasElement` (its 2D context is -/// transparent) and then `draw_image` it onto the SSAA offscreen -/// context centered inside the wrapper's 4:3 letterbox region. -/// The drawImage call stretches the 4:3 source onto a 4:3 -/// destination with `imageSmoothingEnabled = "high"`, which -/// smooths the source's hard edges on the way in. The -/// subsequent `SsaaCanvas::present` does a second -/// `imageSmoothingHigh` downscale from the offscreen to the -/// display canvas, stacking two smoothing passes on the same -/// edge pixels — exactly the dual-stage SSAA the 3D game page -/// uses for its cube and ball rendering. -/// -/// The 4:3 destination rectangle is computed from the live canvas -/// CSS box: `min(width, height * 4/3)` wide, `min(height, width * -/// 3/4)` tall, centered. Inline (3:2) CSS boxes get a horizontal -/// letterbox (CSS box wider than 4:3); fullscreen (16:9) CSS boxes -/// get a vertical letterbox (CSS box taller than 4:3). Either way -/// the destination is a 4:3 rectangle that exactly fits the ladder -/// framebuffer's native aspect ratio so the drawImage call does no -/// additional aspect-ratio stretch. +/// Releases every resource a RayTrace loop owns, on both teardown paths. +/// +/// `App::use_cleanup` fires on a match-arm switch, but a router +/// navigation mounts a different page without running any cleanup, so a +/// loop whose canvas left the document reaches its own teardown from the +/// animation-frame body instead. Both paths converge on this one +/// function, which is idempotent: every step either checks a handle or +/// takes a value out of its owner, so running it twice releases nothing +/// twice. +/// +/// The animation-frame closure is safe to take even when this runs from +/// inside that very closure, which is the case on the router-navigation +/// path. wasm-bindgen 0.2.129 makes `makeMutClosure` bump the closure's +/// own reference count for the duration of the call and release it in a +/// `finally`, so dropping the Rust `Closure` mid-invocation only lowers +/// the count to one and the Rust environment stays alive until the +/// callback returns. The frame body can therefore keep using every cell +/// it captured after this function returns. /// /// # Arguments /// -/// - `&SsaaCanvas` - The SSAA wrapper whose offscreen context is -/// the draw target. -/// - `&mut [u8]` - The RGBA framebuffer (length `width * height * 4`). -/// - `u32` - The framebuffer width in pixels. -/// - `u32` - The framebuffer height in pixels. -fn present_raytrace_framebuffer( - ssaa_canvas: &SsaaCanvas, - buffer: &mut [u8], - width: u32, - height: u32, +/// - `Rc>` - The cancellation flag the loop body checks. +/// - `Rc>>` - The pending animation-frame handle. +/// - `RafClosureCell` - The cell owning the animation-frame closure. +/// - `RayTracePendingTimers` - The owner list of +/// one-shot timers this loop armed. +/// - `Rc>>` - The cell owning the engine +/// backend. +fn raytrace_release_loop( + cancelled: Rc>, + raf_id: Rc>>, + closure_cell: RafClosureCell, + pending_timers: RayTracePendingTimers, + renderer_rc: Rc>>, ) { - let Some(window_value): Option = window() else { - return; - }; - let Some(document_value): Option = window_value.document() else { - return; - }; - let Ok(source_canvas): Result = document_value - .create_element("canvas") - .map(|element: Element| element.unchecked_into()) - else { - return; - }; - source_canvas.set_width(width); - source_canvas.set_height(height); - let Ok(Some(source_context_object)): Result, JsValue> = - source_canvas.get_context(RAYTRACE_CONTEXT_TYPE) - else { - return; - }; - let source_context: CanvasRenderingContext2d = source_context_object.unchecked_into(); - let image_data: Result = - ImageData::new_with_u8_clamped_array_and_sh(Clamped(buffer), width, height); - let Ok(image_data) = image_data else { - return; - }; - let _: Result<(), JsValue> = source_context.put_image_data(&image_data, 0.0, 0.0); - let offscreen_context: &CanvasRenderingContext2d = ssaa_canvas.get_offscreen_context(); - let offscreen_width: f64 = ssaa_canvas.get_width(); - let offscreen_height: f64 = ssaa_canvas.get_height(); - let dest_w: f64 = offscreen_width.min(offscreen_height * 4.0 / 3.0); - let dest_h: f64 = offscreen_height.min(offscreen_width * 3.0 / 4.0); - let dest_x: f64 = (offscreen_width - dest_w) * 0.5; - let dest_y: f64 = (offscreen_height - dest_h) * 0.5; - let _: Result<(), JsValue> = offscreen_context - .draw_image_with_html_canvas_element_and_dw_and_dh( - &source_canvas, - dest_x, - dest_y, - dest_w, - dest_h, - ); - ssaa_canvas.present(); + cancelled.set(true); + if let Some(cancel_id) = raf_id.get() + && let Some(window_value) = window() + { + let _: Result<(), JsValue> = window_value.cancel_animation_frame(cancel_id); + } + raf_id.set(None); + raytrace_release_pending_timers(pending_timers); + let _: Option> = closure_cell.try_take(); + if let Some(mut renderer) = renderer_rc + .try_borrow_mut() + .ok() + .and_then(|mut slot: RefMut<'_, Option>| slot.take()) + { + renderer.dispose(); + } } /// Starts the RayTrace Canvas 2D `requestAnimationFrame` loop. /// -/// Per frame: applies auto-rotate yaw if enabled, rebuilds lighting -/// from the current yaw, re-traces every pixel into the persistent RGBA -/// framebuffer, and uploads it with one `put_image_data` call. An -/// exponential moving average of the CPU render time drives the +/// The loop owns exactly one [`RayTraceRenderer`] built through the +/// engine's `RayTraceRenderer::init` with the requested +/// [`RayTraceBackend::Canvas2D`]. Every frame hands that same backend the +/// orbit camera, the shared lighting, and the shared settings; the scene is +/// uploaded once at `init` and never re-sent, because neither the geometry +/// nor the lights move with the camera. +/// +/// An exponential moving average of the measured render time drives the /// [`RAYTRACE_RENDER_SCALES`] adaptive-resolution ladder: sustained /// frames above 115% of the 60 FPS budget step the internal resolution -/// down one rung, sustained frames below 75% step it back up one rung, -/// and sustained frames below 45% step it up two rungs at once. The -/// FPS counter uses unclamped wall-clock elapsed time so it reports -/// honest rates even below 4 FPS; the `0.25s` clamp applies only to -/// the yaw animation step. The `use_cleanup` cancellation and -/// canvas-detached guard mirror the game_2d / game_3d pattern. +/// down one rung, sustained frames below 75% step it back up, and +/// sustained frames below 45% step it up two rungs at once. The reported +/// FPS uses unclamped wall-clock elapsed time so it stays honest even +/// below 4 FPS; the `0.25s` clamp applies only to the yaw animation step. +/// +/// A rung change is a real size change, so it is committed through the +/// same [`raytrace_resize_backend_to_canvas`] path the CSS box uses, and +/// only on the frames where the rung actually moved: the camera is built +/// for the *previous* frame's size, the new size is adopted after that +/// frame renders, and a rejected resize leaves both the backend and the +/// canvas on their old size instead of presenting a mismatch. /// /// # Arguments /// /// - `UseRayTrace` - The RayTrace Canvas 2D tab state. /// - `RayTraceCameraAngles` - The non-reactive camera orbit angles. pub(crate) fn start_raytrace_loop(state: UseRayTrace, angles: RayTraceCameraAngles) { + let init_state: UseRayTrace = state; + let loop_state: UseRayTrace = state; + let renderer_rc: Rc>> = Rc::new(RefCell::new(None)); + let cancelled: Rc> = Rc::new(Cell::new(false)); let raf_id: Rc>> = Rc::new(Cell::new(None)); let closure_cell: RafClosureCell = Rc::new(MaybeEngineCell::new()); - let last_time: Rc> = Rc::new(Cell::new(-1.0)); - let frame_count: Rc> = Rc::new(Cell::new(0)); - let fps_timer: Rc> = Rc::new(Cell::new(0.0)); - // Only the eye is hoisted: it is constant, so specular highlights - // stay stable as the user orbits. The occluder list is intentionally - // discarded here because the visible sun sphere's position is a - // function of yaw, so the scene (and the `RayTraceScene` that owns - // the precomputed shadow bounding spheres) is rebuilt inside the - // frame callback to stay in lock-step with the rendered sun. - let (_, eye): (Vec, Vector3D) = build_raytrace_scene(0.0); - // The `SsaaCanvas` is rebuilt from scratch on a CSS-box resize - // (its constructor re-acquires the display canvas, resizes the - // display backing store, and allocates a new offscreen canvas at - // the new scale). Storing `None` in the cache and re-acquiring on - // divergence mirrors the game_2d Canvas 2D pattern (see - // `handle_rescale_dirty_canvas2d` in `game_2d/hook/fn.rs`). - let ssaa_cache: Rc>> = - Rc::new(RefCell::new(None)); - let last_canvas_size: Rc> = Rc::new(RefCell::new((0.0, 0.0))); - let framebuffer: Rc>> = Rc::new(RefCell::new(Vec::new())); - // Start at ladder index 7 (scale 1.0): weak clients never start - // heavy, and the controller climbs toward 4.0 only after sustained - // fast frames prove the budget allows it. - let scale_index: Rc> = Rc::new(Cell::new(7)); - let ema_millis: Rc> = Rc::new(Cell::new(0.0)); - let slow_frames: Rc> = Rc::new(Cell::new(0)); - let fast_frames: Rc> = Rc::new(Cell::new(0)); - let very_fast_frames: Rc> = Rc::new(Cell::new(0)); - let last_clone: Rc> = last_time.clone(); - let frame_clone: Rc> = frame_count.clone(); - let fps_clone: Rc> = fps_timer.clone(); - let raf_clone: Rc>> = raf_id.clone(); - let cell_clone: RafClosureCell = closure_cell.clone(); - let yaw_clone: Rc> = angles.yaw.clone(); - let pitch_clone: Rc> = angles.pitch.clone(); - let cache_clone: Rc>> = ssaa_cache.clone(); - let last_size_clone: Rc> = last_canvas_size.clone(); - let buffer_clone: Rc>> = framebuffer.clone(); - let scale_clone: Rc> = scale_index.clone(); - let ema_clone: Rc> = ema_millis.clone(); - let slow_clone: Rc> = slow_frames.clone(); - let fast_clone: Rc> = fast_frames.clone(); - let very_fast_clone: Rc> = very_fast_frames.clone(); - let state_for_loop: UseRayTrace = state; - // Paint the loading overlay *before* the first frame so the user - // sees a centered "Initializing..." line during the SSAA acquire - // + first warmup ray pass. The 200-400 ms window is short - // enough that the overlay usually disappears in a single frame, - // but synchronous WASM module init can delay it further on slow - // devices, and without this paint the canvas stays blank / - // half-rendered for that entire window. - if let Some(window_value) = window() { - let loading_closure: Closure = Closure::wrap(Box::new(move || { - draw_game_3d_loading(RAYTRACE_LOADING_CANVAS_SELECTOR, RAYTRACE_CANVAS_SELECTOR); - })); - let loading_callback: Function = - loading_closure.as_ref().unchecked_ref::().clone(); - loading_closure.forget(); - let _: Result = window_value - .set_timeout_with_callback_and_timeout_and_arguments_0(&loading_callback, 0); - } - let raf_closure: Closure = Closure::wrap(Box::new(move || { - if raytrace_canvas_detached(RAYTRACE_CANVAS_SELECTOR) { - return; - } - let Some(window_value): Option = window() else { + let pending_timers: RayTracePendingTimers = Rc::new(RefCell::new(Vec::new())); + let renderer_for_cleanup: Rc>> = renderer_rc.clone(); + let raf_for_cleanup: Rc>> = raf_id.clone(); + let cell_for_cleanup: RafClosureCell = closure_cell.clone(); + let cancelled_for_cleanup: Rc> = cancelled.clone(); + let timers_for_cleanup: RayTracePendingTimers = pending_timers.clone(); + // `dispose` is idempotent by contract, so calling it on the cleanup + // path and again on the `Drop` of the dropped renderer cannot + // double-free: the second call is a no-op on an already-disposed + // backend. Taking the value out of the cell releases the last Rust + // reference as well, which is what lets the renderer run its `Drop`. + App::use_cleanup(move || { + raytrace_release_loop( + cancelled_for_cleanup, + raf_for_cleanup, + cell_for_cleanup, + timers_for_cleanup, + renderer_for_cleanup, + ); + }); + let cancelled_for_init: Rc> = cancelled.clone(); + let pending_timers_for_init: RayTracePendingTimers = pending_timers.clone(); + // Owned by the same pending-timer list as every other one-shot, so + // teardown drops the Rust closure instead of leaking it. The DOM + // still holds a live reference to the trampoline until the timer + // fires, which is what the timeout handle then detaches. + raytrace_schedule_one_shot(pending_timers_for_init.clone(), 0, || { + draw_game_3d_loading(RAYTRACE_LOADING_CANVAS_SELECTOR, RAYTRACE_CANVAS_SELECTOR); + }); + spawn_local(async move { + let Some(canvas): Option = + query_raytrace_canvas(RAYTRACE_CANVAS_SELECTOR) + else { + init_state.get_active().set(false); + init_state + .get_init_error_code() + .set(RAYTRACE_ERROR_CODE_INIT); + init_state.get_error_detail().set(String::new()); + init_state.get_loaded().set(true); return; }; - let Some(performance): Option = window_value.performance() else { + // The scene and the lights are both static, so both are built + // exactly once here and handed to the engine. From this point on + // no frame allocates, clones, or re-packs either one. + let scene: RayTraceScene = build_raytrace_scene(); + let renderer: RayTraceRenderer = + match RayTraceRenderer::init(canvas, RayTraceBackend::Canvas2D, &scene).await { + Ok(value) => value, + Err(error) => { + raytrace_record_error( + init_state.get_active(), + init_state.get_error_detail(), + init_state.get_init_error_code(), + RAYTRACE_ERROR_CODE_INIT, + "canvas2d init", + &error, + ); + init_state.get_loaded().set(true); + return; + } + }; + if cancelled_for_init.get() { + let mut stale: RayTraceRenderer = renderer; + stale.dispose(); return; - }; - let current_time: f64 = performance.now() / 1000.0; - let prev: f64 = last_clone.get(); - // The unclamped frame time feeds the FPS counter so the reported - // rate stays honest even when frames take multiple seconds; only - // the yaw animation step consumes the clamped delta. - let frame_time: f64 = if prev < 0.0 { - 1.0 / 60.0 - } else { - current_time - prev - }; - let anim_time: f64 = frame_time.min(0.25); - last_clone.set(current_time); - if state_for_loop.get_auto_rotate().get() { - yaw_clone.set(yaw_clone.get() + RAYTRACE_AUTO_YAW_SPEED * anim_time); } - let yaw: f64 = yaw_clone.get(); - let pitch: f64 = pitch_clone.get(); - if state_for_loop.get_running().get() { - // Resize the SSAA wrapper when the live CSS box diverges - // from the cached size. `getBoundingClientRect` is the - // only reading that tracks the live layout; using - // `canvas.width` / `canvas.height` would loop on the - // backing store we just wrote. - let (live_w, live_h): (f64, f64) = - read_raytrace_canvas_size(RAYTRACE_CANVAS_SELECTOR).unwrap_or((0.0, 0.0)); - let (cached_w, cached_h) = *last_size_clone.borrow(); - let needs_reacquire: bool = live_w > 0.0 - && live_h > 0.0 - && (cached_w <= 0.0 - || cached_h <= 0.0 - || (live_w - cached_w).abs() > 1.5 - || (live_h - cached_h).abs() > 1.5); - if needs_reacquire { - if let Some((_, ssaa_canvas)) = acquire_raytrace_ssaa_canvas() { - *cache_clone.borrow_mut() = - Some((ssaa_canvas.get_display_canvas().clone(), ssaa_canvas)); - } - *last_size_clone.borrow_mut() = (live_w, live_h); + Console::log(format!( + "{RAYTRACE_LOG_PREFIX} {RAYTRACE_LOG_BACKEND_READY} (2d)" + )); + *renderer_rc.borrow_mut() = Some(renderer); + raytrace_set_loaded_delayed( + pending_timers_for_init, + init_state.get_loaded(), + RAYTRACE_LOADING_MIN_MILLIS, + ); + let lights: LightingUniforms = build_raytrace_lighting(); + let settings: RayTraceSettings = build_raytrace_settings(); + let scale_index: Rc> = Rc::new(Cell::new(RAYTRACE_RENDER_SCALE_START_INDEX)); + let ema_millis: Rc> = Rc::new(Cell::new(0.0)); + let slow_frames: Rc> = Rc::new(Cell::new(0)); + let fast_frames: Rc> = Rc::new(Cell::new(0)); + let very_fast_frames: Rc> = Rc::new(Cell::new(0)); + let last_time: Rc> = Rc::new(Cell::new(-1.0)); + let frame_count: Rc> = Rc::new(Cell::new(0)); + let fps_timer: Rc> = Rc::new(Cell::new(0.0)); + let renderer_for_loop: Rc>> = renderer_rc.clone(); + let raf_clone: Rc>> = raf_id.clone(); + let cell_clone: RafClosureCell = closure_cell.clone(); + let last_clone: Rc> = last_time.clone(); + let frame_clone: Rc> = frame_count.clone(); + let fps_clone: Rc> = fps_timer.clone(); + let scale_clone: Rc> = scale_index.clone(); + let ema_clone: Rc> = ema_millis.clone(); + let slow_clone: Rc> = slow_frames.clone(); + let fast_clone: Rc> = fast_frames.clone(); + let very_fast_clone: Rc> = very_fast_frames.clone(); + let yaw_clone: Rc> = angles.yaw.clone(); + let pitch_clone: Rc> = angles.pitch.clone(); + let cancelled_for_loop: Rc> = cancelled.clone(); + let raf_for_detach: Rc>> = raf_id.clone(); + let cell_for_detach: RafClosureCell = closure_cell.clone(); + let timers_for_detach: RayTracePendingTimers = pending_timers.clone(); + let renderer_for_detach: Rc>> = renderer_rc.clone(); + let timers_for_loop: RayTracePendingTimers = pending_timers.clone(); + let raf_closure: Closure = Closure::wrap(Box::new(move || { + if cancelled_for_loop.get() { + return; + } + if raytrace_canvas_detached(RAYTRACE_CANVAS_SELECTOR) { + // Router navigation mounts another page without running + // any hook cleanup, so the loop has to reach its own + // teardown. The frame body is already unwound by the time + // this call returns, and the release itself defers the + // last drop to a microtask, so the closure being taken + // from underneath its own frame is safe. + raytrace_release_loop( + cancelled_for_loop.clone(), + raf_for_detach.clone(), + cell_for_detach.clone(), + timers_for_detach.clone(), + renderer_for_detach.clone(), + ); + return; } - let scale: f64 = RAYTRACE_RENDER_SCALES[scale_clone.get()]; - let (frame_width, frame_height): (u32, u32) = raytrace_scaled_dimensions(scale); - if let Some((_canvas, ssaa_canvas)) = cache_clone.borrow().as_ref() { - // Rebuild the scene every tick: the visible sun sphere - // is positioned along the sun direction, which rotates - // with yaw. `RayTraceScene::new` precomputes - // `shadow_points` for the engine's `soft_shadow_factor`, - // so the bounding-sphere cache has to be refreshed in - // lock-step with the occluder positions. - let (occluders, _): (Vec, Vector3D) = build_raytrace_scene(yaw); - let scene: RayTraceScene = RayTraceScene::new(occluders); - let lights: LightingUniforms = build_raytrace_lighting(eye, yaw); + let Some(window_value): Option = window() else { + return; + }; + let Some(performance): Option = window_value.performance() else { + return; + }; + let current_time: f64 = performance.now() / 1000.0; + let prev: f64 = last_clone.get(); + // The unclamped frame time feeds the FPS counter so the reported + // rate stays honest even when frames take multiple seconds; only + // the yaw animation step consumes the clamped delta. + let frame_time: f64 = if prev < 0.0 { + 1.0 / 60.0 + } else { + current_time - prev + }; + let anim_time: f64 = frame_time.min(0.25); + last_clone.set(current_time); + if loop_state.get_auto_rotate().get() { + yaw_clone.set(yaw_clone.get() + RAYTRACE_AUTO_YAW_SPEED * anim_time); + } + if loop_state.get_running().get() { + let scale: f64 = RAYTRACE_RENDER_SCALES[scale_clone.get()]; + let (frame_width, frame_height): (u32, u32) = raytrace_scaled_dimensions(scale); + let camera: Camera3D = build_raytrace_camera( + yaw_clone.get(), + pitch_clone.get(), + frame_width, + frame_height, + ); let render_start: f64 = performance.now(); - { - let mut buffer: RefMut<'_, Vec> = buffer_clone.borrow_mut(); - let needed: usize = frame_width as usize * frame_height as usize * 4; - if buffer.len() != needed { - buffer.resize(needed, 0); + let render_result: Result<(), JsValue> = + match renderer_for_loop.borrow_mut().as_mut() { + Some(renderer) => renderer.render(&camera, &lights, &settings), + None => Err(JsValue::NULL), + }; + let render_millis: f64 = performance.now() - render_start; + match render_result { + Ok(()) => { + raytrace_clear_error( + loop_state.get_active(), + loop_state.get_error_detail(), + loop_state.get_init_error_code(), + ); + if !loop_state.get_loaded().get() { + raytrace_set_loaded_delayed( + timers_for_loop.clone(), + loop_state.get_loaded(), + RAYTRACE_LOADING_MIN_MILLIS, + ); + } + } + Err(error) => { + raytrace_record_error( + loop_state.get_active(), + loop_state.get_error_detail(), + loop_state.get_init_error_code(), + RAYTRACE_ERROR_CODE_RENDER, + "canvas2d render", + &error, + ); } - render_raytrace_frame( - &mut buffer, - frame_width, - frame_height, - &scene, - &lights, - yaw, - pitch, - ); - present_raytrace_framebuffer( - ssaa_canvas, - &mut buffer, - frame_width, - frame_height, - ); } - let render_millis: f64 = performance.now() - render_start; let ema_prev: f64 = ema_clone.get(); let ema: f64 = if ema_prev <= 0.0 { render_millis @@ -773,10 +1001,10 @@ pub(crate) fn start_raytrace_loop(state: UseRayTrace, angles: RayTraceCameraAngl { next = index + 1; } else if fast_clone.get() >= RAYTRACE_ADAPT_FAST_FRAMES && index > 0 { - // Sustained headroom far below the budget skips a - // rung so strong hardware reaches the sharp 4.0 top - // of the ladder in a handful of steps instead of - // crawling one rung at a time. + // Sustained headroom far below the budget skips a rung + // so strong hardware reaches the sharp top of the ladder + // in a handful of steps instead of crawling one at a + // time. next = if very_fast_clone.get() >= RAYTRACE_ADAPT_FAST_FRAMES { index.saturating_sub(2) } else { @@ -784,171 +1012,975 @@ pub(crate) fn start_raytrace_loop(state: UseRayTrace, angles: RayTraceCameraAngl }; } if next != index { - scale_clone.set(next); - slow_clone.set(0); - fast_clone.set(0); - very_fast_clone.set(0); - state_for_loop - .get_render_scale() - .set(RAYTRACE_RENDER_SCALES[next]); - } - // Flip the active / loaded flags on the first successful - // frame so the loading overlay unloads and the - // `Status: ...` banner reports a live renderer. The - // `loaded` set is delayed by - // `RAYTRACE_CANVAS_2D_LOADING_MIN_MILLIS` so the - // overlay stays painted for a minimum visible duration - // even when the SSAA acquire + first frame finishes - // in less than a frame budget — the same UX the - // WebGL / WebGPU tabs use via `raytrace_set_loaded_delayed`. - if !state_for_loop.get_active().get() { - state_for_loop.get_active().set(true); - raytrace_set_loaded_delayed_canvas2d( - state_for_loop.get_loaded(), - RAYTRACE_CANVAS_2D_LOADING_MIN_MILLIS, - ); - } + // A rung change is a real surface change: the frame + // above just rendered at `index`, so the new size is + // committed now, through the one path that owns the + // canvas backing store. A rejected resize reports + // itself and leaves both the backend and the canvas + // on the size the frame used, so the ladder's + // `scale_index` is rolled back with it instead of + // leaving the next frame building a camera for a + // surface the backend does not have. + let (next_width, next_height): (u32, u32) = + raytrace_scaled_dimensions(RAYTRACE_RENDER_SCALES[next]); + let resized: bool = match renderer_for_loop.try_borrow_mut().ok() { + Some(mut renderer_slot) => match renderer_slot.as_mut() { + Some(renderer) => renderer.resize(next_width, next_height).is_ok(), + None => false, + }, + None => false, + }; + if resized { + scale_clone.set(next); + slow_clone.set(0); + fast_clone.set(0); + very_fast_clone.set(0); + loop_state + .get_render_scale() + .set(RAYTRACE_RENDER_SCALES[next]); + } else { + raytrace_record_error( + loop_state.get_active(), + loop_state.get_error_detail(), + loop_state.get_init_error_code(), + RAYTRACE_ERROR_CODE_RESIZE, + "render scale", + &JsValue::NULL, + ); + // Reset the streak so the controller does not + // re-attempt the same rejected rung on the very + // next frame. + slow_clone.set(0); + fast_clone.set(0); + very_fast_clone.set(0); + } + } + } + // Only a frame that actually rendered is a rendered frame: + // counting the ticks a paused loop still receives would let + // the FPS readout claim 60 FPS while nothing is drawn. + if loop_state.get_running().get() { + frame_clone.set(frame_clone.get() + 1); + fps_clone.set(fps_clone.get() + frame_time); + if fps_clone.get() >= 1.0 { + let fps: f64 = f64::from(frame_clone.get()) / fps_clone.get(); + loop_state.get_fps().set(fps); + frame_clone.set(0); + fps_clone.set(0.0); + } } + let Some(raf_closure_ref): Option<&'static Closure> = cell_clone.try_get() + else { + return; + }; + let next_id: i32 = window_value + .request_animation_frame(raf_closure_ref.as_ref().unchecked_ref()) + .unwrap_or_default(); + raf_clone.set(Some(next_id)); + })); + let _: Result<(), _> = closure_cell.try_set(raf_closure); + let raf_for_start: Rc>> = raf_id.clone(); + let cell_for_start: RafClosureCell = closure_cell.clone(); + // The delayed kickoff is a one-shot timer owned by the same + // pending-timer list as the loading flip, so teardown cancels it + // and drops its closure instead of leaking it. The list is + // drained on teardown and is not reused afterwards, so no pruning + // of already-fired entries is needed. + raytrace_schedule_one_shot( + pending_timers.clone(), + RAYTRACE_LOOP_START_DELAY_MILLIS, + move || { + let Some(start_window): Option = window() else { + return; + }; + let Some(start_raf_ref): Option<&'static Closure> = + cell_for_start.try_get() + else { + return; + }; + let start_id: i32 = start_window + .request_animation_frame(start_raf_ref.as_ref().unchecked_ref()) + .unwrap_or_default(); + raf_for_start.set(Some(start_id)); + }, + ); + }); +} + +/// Starts the RayTrace WebGL loop driven by `requestAnimationFrame`. +/// +/// The loop owns exactly one [`RayTraceRenderer`] built through the +/// engine's `RayTraceRenderer::init` with the requested +/// [`RayTraceBackend::WebGl2`]. The demo owns no GLSL and no WGSL of its +/// own: the engine compiles and links its shared shaders during `init`, +/// uploads the packed scene once, and every frame after that is a +/// camera / lights / settings update against persistent engine-side +/// textures and program objects. +/// +/// # Arguments +/// +/// - `UseRayTraceWebGl` - The WebGL backend state for signal updates. +/// - `RayTraceCameraAngles` - The shared non-reactive camera orbit angles. +pub(crate) fn start_raytrace_webgl_loop(state: UseRayTraceWebGl, angles: RayTraceCameraAngles) { + let init_state: UseRayTraceWebGl = state; + let loop_state: UseRayTraceWebGl = state; + let renderer_rc: Rc>> = Rc::new(RefCell::new(None)); + let cancelled: Rc> = Rc::new(Cell::new(false)); + let raf_id: Rc>> = Rc::new(Cell::new(None)); + let closure_cell: RafClosureCell = Rc::new(MaybeEngineCell::new()); + let resize_dirty: Rc> = Rc::new(Cell::new(false)); + let resize_timer: Rc>> = Rc::new(Cell::new(None)); + let observer_cell: Rc>> = Rc::new(RefCell::new(None)); + // Owns the ResizeObserver's Rust callback. The observer is built + // asynchronously below, but its closure outlives this function, so + // the slot is declared here where the cleanup can already reach it. + let observer_closure_cell: RayTraceObserverClosureOwner = Rc::new(RefCell::new(None)); + let pending_timers: RayTracePendingTimers = Rc::new(RefCell::new(Vec::new())); + let debounce_owner: RayTraceDebounceOwner = raytrace_register_resize_debounce( + resize_dirty.clone(), + resize_timer.clone(), + cancelled.clone(), + ); + let renderer_for_cleanup: Rc>> = renderer_rc.clone(); + let raf_for_cleanup: Rc>> = raf_id.clone(); + let cell_for_cleanup: RafClosureCell = closure_cell.clone(); + let cancelled_for_cleanup: Rc> = cancelled.clone(); + let resize_timer_for_cleanup: Rc>> = resize_timer.clone(); + let observer_for_cleanup: Rc>> = observer_cell.clone(); + let observer_closure_for_cleanup: RayTraceObserverClosureOwner = observer_closure_cell.clone(); + let timers_for_cleanup: RayTracePendingTimers = pending_timers.clone(); + let debounce_for_cleanup: RayTraceDebounceOwner = debounce_owner; + App::use_cleanup(move || { + raytrace_release_loop( + cancelled_for_cleanup, + raf_for_cleanup, + cell_for_cleanup, + timers_for_cleanup, + renderer_for_cleanup, + ); + // Every step below is independent: a missing `window()` must + // never skip the observer teardown, so no early returns here. + if let Some(timer_id) = resize_timer_for_cleanup.get() + && let Some(window_value) = window() + { + window_value.clear_timeout_with_handle(timer_id); } - frame_clone.set(frame_clone.get() + 1); - fps_clone.set(fps_clone.get() + frame_time); - if fps_clone.get() >= 1.0 { - let fps: f64 = f64::from(frame_clone.get()) / fps_clone.get(); - state_for_loop.get_fps().set(fps); - frame_clone.set(0); - fps_clone.set(0.0); + // Disconnect the ResizeObserver so its closure (and the renderer + // it holds) is released on tab switch. Without this the observer + // keeps the renderer alive past the loop's lifetime, holding GPU + // resources until GC. + if let Some(observer) = observer_for_cleanup + .try_borrow_mut() + .ok() + .and_then(|mut observer_slot: RefMut<'_, Option>| observer_slot.take()) + { + observer.disconnect(); } - let Some(raf_closure_ref): Option<&'static Closure> = cell_clone.try_get() + // Dropped only after `disconnect()`: while the observer is live + // the browser may call the trampoline at any point, and a freed + // one aborts the whole WASM instance. + let _: Option> = + observer_closure_for_cleanup + .try_borrow_mut() + .ok() + .and_then(|mut closure_slot: RayTraceObserverCallbackSlot<'_>| closure_slot.take()); + // Released only here: the resize listener stays registered + // across a router navigation, and dropping its callback while it + // can still be invoked would make the next window resize throw. + let _: Option> = debounce_for_cleanup + .try_borrow_mut() + .ok() + .and_then(|mut debounce_slot: RayTraceDebounceCallbackSlot<'_>| debounce_slot.take()); + }); + let cancelled_for_init: Rc> = cancelled.clone(); + let pending_timers_for_init: RayTracePendingTimers = pending_timers.clone(); + let observer_closure_cell_for_init: RayTraceObserverClosureOwner = + observer_closure_cell.clone(); + // Owned by the same pending-timer list as every other one-shot, so + // teardown drops the Rust closure instead of leaking it. The DOM + // still holds a live reference to the trampoline until the timer + // fires, which is what the timeout handle then detaches. + raytrace_schedule_one_shot(pending_timers_for_init.clone(), 0, || { + draw_game_3d_loading( + RAYTRACE_WEBGL_LOADING_CANVAS_SELECTOR, + RAYTRACE_WEBGL_CANVAS_SELECTOR, + ); + }); + spawn_local(async move { + let Some(canvas): Option = + query_raytrace_canvas(RAYTRACE_WEBGL_CANVAS_SELECTOR) else { + init_state.get_active().set(false); + init_state + .get_init_error_code() + .set(RAYTRACE_ERROR_CODE_INIT); + init_state.get_error_detail().set(String::new()); + init_state.get_loaded().set(true); return; }; - let next_id: i32 = window_value - .request_animation_frame(raf_closure_ref.as_ref().unchecked_ref()) - .unwrap_or_default(); - raf_clone.set(Some(next_id)); - })); - let _: Result<(), _> = closure_cell.try_set(raf_closure); - let start_timeout_id: Rc>> = Rc::new(Cell::new(None)); - let start_timeout_clone: Rc>> = start_timeout_id.clone(); - let raf_for_start: Rc>> = raf_id.clone(); - let cell_for_start: RafClosureCell = closure_cell.clone(); - let start_closure: Closure = Closure::wrap(Box::new(move || { + // Static scene: built once, uploaded once, never re-sent. The GPU + // backends repack it into the shared `vec4` layout inside `init`. + let scene: RayTraceScene = build_raytrace_scene(); + let renderer: RayTraceRenderer = + match RayTraceRenderer::init(canvas, RayTraceBackend::WebGl2, &scene).await { + Ok(value) => value, + Err(error) => { + raytrace_record_error( + init_state.get_active(), + init_state.get_error_detail(), + init_state.get_init_error_code(), + RAYTRACE_ERROR_CODE_INIT, + "webgl2 init", + &error, + ); + init_state.get_loaded().set(true); + return; + } + }; + if cancelled_for_init.get() { + let mut stale: RayTraceRenderer = renderer; + stale.dispose(); + return; + } + Console::log(format!( + "{RAYTRACE_LOG_PREFIX} {RAYTRACE_LOG_BACKEND_READY} (gl)" + )); + *renderer_rc.borrow_mut() = Some(renderer); + raytrace_set_loaded_delayed( + pending_timers_for_init, + init_state.get_loaded(), + RAYTRACE_LOADING_MIN_MILLIS, + ); + let lights: LightingUniforms = build_raytrace_lighting(); + let settings: RayTraceSettings = build_raytrace_settings(); + // Synchronous resize on CSS-box change. ResizeObserver callbacks run + // BEFORE the browser paints the next frame, so resizing inside the + // observer collapses the stretched-frame window after a fullscreen + // transition to zero. + let renderer_for_observer: Rc>> = renderer_rc.clone(); + let observer_closure: Closure = Closure::wrap( + Box::new(move |_entries: js_sys::Array, _obs: ResizeObserver| { + let renderer_for_frame: Rc>> = + renderer_for_observer.clone(); + let resize_accepted: bool = match renderer_for_frame.try_borrow_mut() { + Ok(mut renderer_slot) => match renderer_slot.as_mut() { + Some(renderer) => raytrace_resize_backend_to_canvas( + renderer, + RAYTRACE_WEBGL_CANVAS_SELECTOR, + loop_state.get_active(), + loop_state.get_error_detail(), + loop_state.get_init_error_code(), + ), + // The renderer is gone: a tab switch already + // disposed it, so there is no surface left to + // resize and nothing to report. + None => true, + }, + Err(_contended) => false, + }; + if !resize_accepted { + // `raytrace_resize_backend_to_canvas` already recorded + // the engine's own reason against the status banner; + // this only makes the rejected observer pass visible + // in the console, so a silently frozen surface after a + // fullscreen transition is traceable. + Console::error(format!( + "{RAYTRACE_LOG_PREFIX} resize observer refused the new CSS box; \ + the backend keeps its previous size" + )); + } + }), + ); + let observer_callback: Function = observer_closure + .as_ref() + .unchecked_ref::() + .clone(); + // Ownership moves into the slot the cleanup owns. Dropping it here + // would let the browser invoke a freed trampoline: the observer + // keeps firing after this async init returns. + if let Ok(mut closure_slot) = observer_closure_cell_for_init.try_borrow_mut() { + *closure_slot = Some(observer_closure); + } + if let Ok(resize_observer) = ResizeObserver::new(&observer_callback) + && let Some(window_value) = window() + && let Some(document_value) = window_value.document() + && let Some(element) = document_value + .query_selector(RAYTRACE_WEBGL_CANVAS_SELECTOR) + .ok() + .flatten() + { + resize_observer.observe(&element); + // `try_borrow_mut`, not `borrow_mut`: the tab-switch cleanup + // registered above also mutably borrows this same cell from + // `HookContext::switch_arm`, and this backend's init is + // asynchronous, so the arm can switch while init is still in + // flight and the two borrows genuinely overlap. A + // BorrowMutError in WASM aborts the whole app with no catchable + // panic. On contention the observer is dropped here and the loop + // runs without live resize notifications — the per-frame + // backing-store divergence check still resizes. + if let Ok(mut observer_slot) = observer_cell.try_borrow_mut() { + *observer_slot = Some(resize_observer); + } + } + let last_time: Rc> = Rc::new(Cell::new(-1.0)); + let frame_count: Rc> = Rc::new(Cell::new(0)); + let fps_timer: Rc> = Rc::new(Cell::new(0.0)); + let renderer_for_loop: Rc>> = renderer_rc.clone(); + let raf_clone: Rc>> = raf_id.clone(); + let cell_clone: RafClosureCell = closure_cell.clone(); + let last_clone: Rc> = last_time.clone(); + let frame_clone: Rc> = frame_count.clone(); + let fps_clone: Rc> = fps_timer.clone(); + let yaw_clone: Rc> = angles.yaw.clone(); + let pitch_clone: Rc> = angles.pitch.clone(); + let resize_dirty_for_loop: Rc> = resize_dirty.clone(); + let cancelled_for_loop: Rc> = cancelled.clone(); + let raf_for_detach: Rc>> = raf_id.clone(); + let cell_for_detach: RafClosureCell = closure_cell.clone(); + let timers_for_detach: RayTracePendingTimers = pending_timers.clone(); + let renderer_for_detach: Rc>> = renderer_rc.clone(); + let timers_for_loop: RayTracePendingTimers = pending_timers.clone(); + let raf_closure: Closure = Closure::wrap(Box::new(move || { + if cancelled_for_loop.get() { + return; + } + if raytrace_canvas_detached(RAYTRACE_WEBGL_CANVAS_SELECTOR) { + // Router navigation mounts another page without running + // any hook cleanup, so the loop has to reach its own + // teardown. The ResizeObserver is intentionally *not* + // disconnected here: it is still observed and can still + // fire, so dropping it from under the browser would leave + // a live observer pointing at a disposed backend. It is + // disconnected by the hook cleanup, and until then its + // resize call is a no-op because the renderer is gone. + raytrace_release_loop( + cancelled_for_loop.clone(), + raf_for_detach.clone(), + cell_for_detach.clone(), + timers_for_detach.clone(), + renderer_for_detach.clone(), + ); + return; + } + let Some(window_value): Option = window() else { + return; + }; + let Some(performance): Option = window_value.performance() else { + return; + }; + let current_time: f64 = performance.now() / 1000.0; + let prev: f64 = last_clone.get(); + // Unclamped frame time feeds the FPS counter (honest rate); + // the clamped delta only drives the yaw animation step. + let frame_time: f64 = if prev < 0.0 { + 1.0 / 60.0 + } else { + current_time - prev + }; + let anim_time: f64 = frame_time.min(0.25); + last_clone.set(current_time); + if loop_state.get_auto_rotate().get() { + yaw_clone.set(yaw_clone.get() + RAYTRACE_AUTO_YAW_SPEED * anim_time); + } + if resize_dirty_for_loop.get() { + resize_dirty_for_loop.set(false); + } + // A paused loop renders nothing: the previous frame stays on + // screen and the backend is not touched at all, which is what + // the pause button promises and what keeps a paused tab from + // burning a GPU pass per animation frame. + let running: bool = loop_state.get_running().get(); + let render_result: Option> = if running { + renderer_for_loop.try_borrow_mut().ok().and_then( + |mut renderer_slot: RefMut<'_, Option>| { + let renderer: &mut RayTraceRenderer = renderer_slot.as_mut()?; + // The renderer owns the canvas backing store, so a + // rejected resize leaves both on the old size. The + // frame is then skipped rather than drawn against + // a size the backend does not have. + if !raytrace_resize_backend_to_canvas( + renderer, + RAYTRACE_WEBGL_CANVAS_SELECTOR, + loop_state.get_active(), + loop_state.get_error_detail(), + loop_state.get_init_error_code(), + ) { + return None; + } + // The contract exposes no canvas accessor on the + // renderer, so the backing store is read back off + // the DOM element the loop already owns. + let width: u32 = + raytrace_canvas_backing_width(RAYTRACE_WEBGL_CANVAS_SELECTOR); + let height: u32 = + raytrace_canvas_backing_height(RAYTRACE_WEBGL_CANVAS_SELECTOR); + let camera: Camera3D = build_raytrace_camera( + yaw_clone.get(), + pitch_clone.get(), + width, + height, + ); + Some(renderer.render(&camera, &lights, &settings)) + }, + ) + } else { + None + }; + if running { + match render_result { + Some(Ok(())) => { + raytrace_clear_error( + loop_state.get_active(), + loop_state.get_error_detail(), + loop_state.get_init_error_code(), + ); + if !loop_state.get_loaded().get() { + raytrace_set_loaded_delayed( + timers_for_loop.clone(), + loop_state.get_loaded(), + RAYTRACE_LOADING_MIN_MILLIS, + ); + } + } + Some(Err(error)) => { + raytrace_record_error( + loop_state.get_active(), + loop_state.get_error_detail(), + loop_state.get_init_error_code(), + RAYTRACE_ERROR_CODE_RENDER, + "webgl2 render", + &error, + ); + } + // A rejected resize and a missing renderer both land + // here: neither produced a frame. The resize failure + // already recorded its own code and detail, so it is + // not overwritten with a generic render rejection. + // Control falls through to the frame re-registration + // below either way, which is what lets the loop + // recover once the resize succeeds again. + None => { + if loop_state.get_error_detail().get().is_empty() { + raytrace_record_error( + loop_state.get_active(), + loop_state.get_error_detail(), + loop_state.get_init_error_code(), + RAYTRACE_ERROR_CODE_RENDER, + "webgl2 render", + &JsValue::NULL, + ); + } + } + } + } + // Only a frame that actually rendered counts toward the rate. + if running { + frame_clone.set(frame_clone.get() + 1); + fps_clone.set(fps_clone.get() + frame_time); + if fps_clone.get() >= 1.0 { + let fps: f64 = f64::from(frame_clone.get()) / fps_clone.get(); + loop_state.get_fps().set(fps); + frame_clone.set(0); + fps_clone.set(0.0); + } + } + let Some(raf_closure_ref): Option<&'static Closure> = cell_clone.try_get() + else { + return; + }; + let next_id: i32 = window_value + .request_animation_frame(raf_closure_ref.as_ref().unchecked_ref()) + .unwrap_or_default(); + if cancelled_for_loop.get() { + raf_clone.set(None); + } else { + raf_clone.set(Some(next_id)); + } + })); + let _: Result<(), _> = closure_cell.try_set(raf_closure); let Some(start_window): Option = window() else { return; }; - let Some(start_raf_ref): Option<&'static Closure> = cell_for_start.try_get() + let Some(start_raf_ref): Option<&'static Closure> = closure_cell.try_get() else { return; }; let start_id: i32 = start_window .request_animation_frame(start_raf_ref.as_ref().unchecked_ref()) .unwrap_or_default(); - raf_for_start.set(Some(start_id)); - })); - let start_callback: Function = start_closure.as_ref().unchecked_ref::().clone(); - start_closure.forget(); - let Some(start_window): Option = window() else { - return; - }; - let timeout_id: i32 = start_window - .set_timeout_with_callback_and_timeout_and_arguments_0( - &start_callback, - RAYTRACE_LOOP_START_DELAY_MILLIS, - ) - .unwrap_or_default(); - start_timeout_clone.set(Some(timeout_id)); - let raf_for_cleanup: Rc>> = raf_id.clone(); - let cell_for_cleanup: RafClosureCell = closure_cell.clone(); - App::use_cleanup(move || { - if let Some(cancel_id) = raf_for_cleanup.get() { - let Some(window_value): Option = window() else { - return; - }; - let _: Result<(), JsValue> = window_value.cancel_animation_frame(cancel_id); - } - if let Some(timeout_id) = start_timeout_id.get() { - let Some(window_value): Option = window() else { - return; - }; - window_value.clear_timeout_with_handle(timeout_id); - } - let _: Option<_> = cell_for_cleanup.try_take(); + raf_id.set(Some(start_id)); }); - state.get_loop_started().set(true); -} - -/// Creates a click handler that toggles a RayTrace tab loop between -/// running and paused. -/// -/// # Arguments -/// -/// - `Signal` - The running signal of the active tab's loop. -/// -/// # Returns -/// -/// - `Option>` - The toggle handler. -pub(crate) fn raytrace_on_toggle_pause(running: Signal) -> Option> { - Some(Rc::new(move |_: Event| { - let current: bool = running.get(); - running.set(!current); - })) -} - -/// Creates a click handler that toggles camera auto-rotation. -/// -/// # Arguments -/// -/// - `Signal` - The auto-rotate signal of the active tab's loop. -/// -/// # Returns -/// -/// - `Option>` - The toggle handler. -pub(crate) fn raytrace_on_toggle_auto_rotate( - auto_rotate: Signal, -) -> Option> { - Some(Rc::new(move |_: Event| { - let current: bool = auto_rotate.get(); - auto_rotate.set(!current); - })) } -/// Creates a click handler that resets the camera orbit angles. -/// -/// # Arguments -/// -/// - `RayTraceCameraAngles` - The non-reactive camera orbit angles. -/// -/// # Returns +/// Starts the RayTrace WebGPU loop driven by `requestAnimationFrame`. /// -/// - `Option>` - The toggle handler. -pub(crate) fn raytrace_on_reset_camera(angles: RayTraceCameraAngles) -> Option> { - Some(Rc::new(move |_: Event| { - angles.yaw.set(0.6); - angles.pitch.set(0.25); - })) -} - -/// Reads the client X coordinate off a `MouseEvent`-like object via reflection. +/// The loop owns exactly one [`RayTraceRenderer`] built through the +/// engine's `RayTraceRenderer::init` with the requested +/// [`RayTraceBackend::WebGpu`]. `init` is asynchronous (it awaits the +/// adapter and device, then awaits shader compilation), so the whole +/// sequence runs inside `spawn_local` behind a cancellation guard for tab +/// switches. A rejected init surfaces its error on the status banner and +/// the loop exits quietly rather than falling back to another backend. /// /// # Arguments /// -/// - `&Event` - The pointer event. -/// -/// # Returns -/// -/// - `f64` - The client X coordinate, or `0.0` if missing. -fn client_x_from_event(event: &Event) -> f64 { - Reflect::get( - event.as_ref(), - &JsValue::from_str(RAYTRACE_EVENT_PROPERTY_CLIENT_X), - ) - .ok() - .and_then(|value: JsValue| value.as_f64()) - .unwrap_or_default() -} - -/// Reads the client Y coordinate off a `MouseEvent`-like object via reflection. -/// +/// - `UseRayTraceWebGpu` - The WebGPU backend state for signal updates. +/// - `RayTraceCameraAngles` - The shared non-reactive camera orbit angles. +pub(crate) fn start_raytrace_webgpu_loop(state: UseRayTraceWebGpu, angles: RayTraceCameraAngles) { + let init_state: UseRayTraceWebGpu = state; + let loop_state: UseRayTraceWebGpu = state; + let renderer_rc: Rc>> = Rc::new(RefCell::new(None)); + let cancelled: Rc> = Rc::new(Cell::new(false)); + let raf_id: Rc>> = Rc::new(Cell::new(None)); + let closure_cell: RafClosureCell = Rc::new(MaybeEngineCell::new()); + let resize_dirty: Rc> = Rc::new(Cell::new(false)); + let resize_timer: Rc>> = Rc::new(Cell::new(None)); + let observer_cell: Rc>> = Rc::new(RefCell::new(None)); + // Owns the ResizeObserver's Rust callback. The observer is built + // asynchronously below, but its closure outlives this function, so + // the slot is declared here where the cleanup can already reach it. + let observer_closure_cell: RayTraceObserverClosureOwner = Rc::new(RefCell::new(None)); + let pending_timers: RayTracePendingTimers = Rc::new(RefCell::new(Vec::new())); + let debounce_owner: RayTraceDebounceOwner = raytrace_register_resize_debounce( + resize_dirty.clone(), + resize_timer.clone(), + cancelled.clone(), + ); + let renderer_for_cleanup: Rc>> = renderer_rc.clone(); + let raf_for_cleanup: Rc>> = raf_id.clone(); + let cell_for_cleanup: RafClosureCell = closure_cell.clone(); + let cancelled_for_cleanup: Rc> = cancelled.clone(); + let resize_timer_for_cleanup: Rc>> = resize_timer.clone(); + let observer_for_cleanup: Rc>> = observer_cell.clone(); + let observer_closure_for_cleanup: RayTraceObserverClosureOwner = observer_closure_cell.clone(); + let timers_for_cleanup: RayTracePendingTimers = pending_timers.clone(); + let debounce_for_cleanup: RayTraceDebounceOwner = debounce_owner; + App::use_cleanup(move || { + // `dispose` frees the owned buffers, textures, and device eagerly + // instead of leaving them to GC, which is what lets a fresh + // `init` acquire a live device rather than reusing a dead one. + raytrace_release_loop( + cancelled_for_cleanup, + raf_for_cleanup, + cell_for_cleanup, + timers_for_cleanup, + renderer_for_cleanup, + ); + // Every step below is independent: a missing `window()` must + // never skip the observer teardown, so no early returns here. + if let Some(timer_id) = resize_timer_for_cleanup.get() + && let Some(window_value) = window() + { + window_value.clear_timeout_with_handle(timer_id); + } + if let Some(observer) = observer_for_cleanup + .try_borrow_mut() + .ok() + .and_then(|mut observer_slot: RefMut<'_, Option>| observer_slot.take()) + { + observer.disconnect(); + } + // Dropped only after `disconnect()`: while the observer is live + // the browser may call the trampoline at any point, and a freed + // one aborts the whole WASM instance. + let _: Option> = + observer_closure_for_cleanup + .try_borrow_mut() + .ok() + .and_then(|mut closure_slot: RayTraceObserverCallbackSlot<'_>| closure_slot.take()); + // Released only here: the resize listener stays registered + // across a router navigation, and dropping its callback while it + // can still be invoked would make the next window resize throw. + let _: Option> = debounce_for_cleanup + .try_borrow_mut() + .ok() + .and_then(|mut debounce_slot: RayTraceDebounceCallbackSlot<'_>| debounce_slot.take()); + }); + let cancelled_for_init: Rc> = cancelled.clone(); + let pending_timers_for_init: RayTracePendingTimers = pending_timers.clone(); + let observer_closure_cell_for_init: RayTraceObserverClosureOwner = + observer_closure_cell.clone(); + // Owned by the same pending-timer list as every other one-shot, so + // teardown drops the Rust closure instead of leaking it. The DOM + // still holds a live reference to the trampoline until the timer + // fires, which is what the timeout handle then detaches. + raytrace_schedule_one_shot(pending_timers_for_init.clone(), 0, || { + draw_game_3d_loading( + RAYTRACE_WEBGPU_LOADING_CANVAS_SELECTOR, + RAYTRACE_WEBGPU_CANVAS_SELECTOR, + ); + }); + spawn_local(async move { + let Some(canvas): Option = + query_raytrace_canvas(RAYTRACE_WEBGPU_CANVAS_SELECTOR) + else { + init_state.get_active().set(false); + init_state + .get_init_error_code() + .set(RAYTRACE_ERROR_CODE_INIT); + init_state.get_error_detail().set(String::new()); + init_state.get_loaded().set(true); + return; + }; + let scene: RayTraceScene = build_raytrace_scene(); + let renderer: RayTraceRenderer = + match RayTraceRenderer::init(canvas, RayTraceBackend::WebGpu, &scene).await { + Ok(value) => value, + Err(error) => { + raytrace_record_error( + init_state.get_active(), + init_state.get_error_detail(), + init_state.get_init_error_code(), + RAYTRACE_ERROR_CODE_INIT, + "webgpu init", + &error, + ); + init_state.get_loaded().set(true); + return; + } + }; + if cancelled_for_init.get() { + // The tab was switched away while the adapter / device promises + // were in flight. A successfully-created renderer still owns a + // live GPU device at this point; destroy it eagerly instead of + // leaving it for GC to discover. + let mut stale: RayTraceRenderer = renderer; + stale.dispose(); + return; + } + Console::log(format!( + "{RAYTRACE_LOG_PREFIX} {RAYTRACE_LOG_BACKEND_READY} (gpu)" + )); + *renderer_rc.borrow_mut() = Some(renderer); + raytrace_set_loaded_delayed( + pending_timers_for_init, + init_state.get_loaded(), + RAYTRACE_LOADING_MIN_MILLIS, + ); + let lights: LightingUniforms = build_raytrace_lighting(); + let settings: RayTraceSettings = build_raytrace_settings(); + let renderer_for_observer: Rc>> = renderer_rc.clone(); + let observer_closure: Closure = Closure::wrap( + Box::new(move |_entries: js_sys::Array, _obs: ResizeObserver| { + let renderer_for_frame: Rc>> = + renderer_for_observer.clone(); + let resize_accepted: bool = match renderer_for_frame.try_borrow_mut() { + Ok(mut renderer_slot) => match renderer_slot.as_mut() { + Some(renderer) => raytrace_resize_backend_to_canvas( + renderer, + RAYTRACE_WEBGPU_CANVAS_SELECTOR, + loop_state.get_active(), + loop_state.get_error_detail(), + loop_state.get_init_error_code(), + ), + // The renderer is gone: a tab switch already + // disposed it, so there is no surface left to + // resize and nothing to report. + None => true, + }, + Err(_contended) => false, + }; + if !resize_accepted { + // `raytrace_resize_backend_to_canvas` already recorded + // the engine's own reason against the status banner; + // this only makes the rejected observer pass visible + // in the console, so a silently frozen surface after a + // fullscreen transition is traceable. + Console::error(format!( + "{RAYTRACE_LOG_PREFIX} resize observer refused the new CSS box; \ + the backend keeps its previous size" + )); + } + }), + ); + let observer_callback: Function = observer_closure + .as_ref() + .unchecked_ref::() + .clone(); + // Ownership moves into the slot the cleanup owns. Dropping it here + // would let the browser invoke a freed trampoline: the observer + // keeps firing after this async init returns. + if let Ok(mut closure_slot) = observer_closure_cell_for_init.try_borrow_mut() { + *closure_slot = Some(observer_closure); + } + if let Ok(resize_observer) = ResizeObserver::new(&observer_callback) + && let Some(window_value) = window() + && let Some(document_value) = window_value.document() + && let Some(element) = document_value + .query_selector(RAYTRACE_WEBGPU_CANVAS_SELECTOR) + .ok() + .flatten() + { + resize_observer.observe(&element); + // See the WebGL loop for why `try_borrow_mut` and not + // `borrow_mut`. + if let Ok(mut observer_slot) = observer_cell.try_borrow_mut() { + *observer_slot = Some(resize_observer); + } + } + let last_time: Rc> = Rc::new(Cell::new(-1.0)); + let frame_count: Rc> = Rc::new(Cell::new(0)); + let fps_timer: Rc> = Rc::new(Cell::new(0.0)); + let renderer_for_loop: Rc>> = renderer_rc.clone(); + let raf_clone: Rc>> = raf_id.clone(); + let cell_clone: RafClosureCell = closure_cell.clone(); + let last_clone: Rc> = last_time.clone(); + let frame_clone: Rc> = frame_count.clone(); + let fps_clone: Rc> = fps_timer.clone(); + let yaw_clone: Rc> = angles.yaw.clone(); + let pitch_clone: Rc> = angles.pitch.clone(); + let resize_dirty_for_loop: Rc> = resize_dirty.clone(); + let cancelled_for_loop: Rc> = cancelled.clone(); + let raf_for_detach: Rc>> = raf_id.clone(); + let cell_for_detach: RafClosureCell = closure_cell.clone(); + let timers_for_detach: RayTracePendingTimers = pending_timers.clone(); + let renderer_for_detach: Rc>> = renderer_rc.clone(); + let timers_for_loop: RayTracePendingTimers = pending_timers.clone(); + let raf_closure: Closure = Closure::wrap(Box::new(move || { + if cancelled_for_loop.get() { + return; + } + if raytrace_canvas_detached(RAYTRACE_WEBGPU_CANVAS_SELECTOR) { + // Router navigation mounts another page without running + // any hook cleanup, so the loop has to reach its own + // teardown. The ResizeObserver is intentionally *not* + // disconnected here: it is still observed and can still + // fire, so dropping it from under the browser would leave + // a live observer pointing at a disposed backend. It is + // disconnected by the hook cleanup, and until then its + // resize call is a no-op because the renderer is gone. + raytrace_release_loop( + cancelled_for_loop.clone(), + raf_for_detach.clone(), + cell_for_detach.clone(), + timers_for_detach.clone(), + renderer_for_detach.clone(), + ); + return; + } + let Some(window_value): Option = window() else { + return; + }; + let Some(performance): Option = window_value.performance() else { + return; + }; + let current_time: f64 = performance.now() / 1000.0; + let prev: f64 = last_clone.get(); + let frame_time: f64 = if prev < 0.0 { + 1.0 / 60.0 + } else { + current_time - prev + }; + let anim_time: f64 = frame_time.min(0.25); + last_clone.set(current_time); + if loop_state.get_auto_rotate().get() { + yaw_clone.set(yaw_clone.get() + RAYTRACE_AUTO_YAW_SPEED * anim_time); + } + if resize_dirty_for_loop.get() { + resize_dirty_for_loop.set(false); + } + // A paused loop renders nothing: the previous frame stays on + // screen and the device is not touched at all, which is what + // the pause button promises and what keeps a paused tab from + // burning a GPU pass per animation frame. + let running: bool = loop_state.get_running().get(); + let render_result: Option> = if running { + renderer_for_loop.try_borrow_mut().ok().and_then( + |mut renderer_slot: RefMut<'_, Option>| { + let renderer: &mut RayTraceRenderer = renderer_slot.as_mut()?; + // The renderer owns the canvas backing store, so a + // rejected resize leaves both on the old size. The + // frame is then skipped rather than drawn against + // a size the backend does not have. + if !raytrace_resize_backend_to_canvas( + renderer, + RAYTRACE_WEBGPU_CANVAS_SELECTOR, + loop_state.get_active(), + loop_state.get_error_detail(), + loop_state.get_init_error_code(), + ) { + return None; + } + // The contract exposes no canvas accessor on the + // renderer, so the backing store is read back off + // the DOM element the loop already owns. + let width: u32 = + raytrace_canvas_backing_width(RAYTRACE_WEBGPU_CANVAS_SELECTOR); + let height: u32 = + raytrace_canvas_backing_height(RAYTRACE_WEBGPU_CANVAS_SELECTOR); + let camera: Camera3D = build_raytrace_camera( + yaw_clone.get(), + pitch_clone.get(), + width, + height, + ); + Some(renderer.render(&camera, &lights, &settings)) + }, + ) + } else { + None + }; + if running { + match render_result { + Some(Ok(())) => { + raytrace_clear_error( + loop_state.get_active(), + loop_state.get_error_detail(), + loop_state.get_init_error_code(), + ); + if !loop_state.get_loaded().get() { + raytrace_set_loaded_delayed( + timers_for_loop.clone(), + loop_state.get_loaded(), + RAYTRACE_LOADING_MIN_MILLIS, + ); + } + } + Some(Err(error)) => { + raytrace_record_error( + loop_state.get_active(), + loop_state.get_error_detail(), + loop_state.get_init_error_code(), + RAYTRACE_ERROR_CODE_RENDER, + "webgpu render", + &error, + ); + } + // A rejected resize and a missing renderer both land + // here: neither produced a frame. The resize failure + // already recorded its own code and detail, so it is + // not overwritten with a generic render rejection. + // Control falls through to the frame re-registration + // below either way, which is what lets the loop + // recover once the resize succeeds again. + None => { + if loop_state.get_error_detail().get().is_empty() { + raytrace_record_error( + loop_state.get_active(), + loop_state.get_error_detail(), + loop_state.get_init_error_code(), + RAYTRACE_ERROR_CODE_RENDER, + "webgpu render", + &JsValue::NULL, + ); + } + } + } + } + // Only a frame that actually rendered counts toward the rate. + if running { + frame_clone.set(frame_clone.get() + 1); + fps_clone.set(fps_clone.get() + frame_time); + if fps_clone.get() >= 1.0 { + let fps: f64 = f64::from(frame_clone.get()) / fps_clone.get(); + loop_state.get_fps().set(fps); + frame_clone.set(0); + fps_clone.set(0.0); + } + } + let Some(raf_closure_ref): Option<&'static Closure> = cell_clone.try_get() + else { + return; + }; + let next_id: i32 = window_value + .request_animation_frame(raf_closure_ref.as_ref().unchecked_ref()) + .unwrap_or_default(); + if cancelled_for_loop.get() { + raf_clone.set(None); + } else { + raf_clone.set(Some(next_id)); + } + })); + let _: Result<(), _> = closure_cell.try_set(raf_closure); + let Some(start_window): Option = window() else { + return; + }; + let Some(start_raf_ref): Option<&'static Closure> = closure_cell.try_get() + else { + return; + }; + let start_id: i32 = start_window + .request_animation_frame(start_raf_ref.as_ref().unchecked_ref()) + .unwrap_or_default(); + raf_id.set(Some(start_id)); + }); +} + +/// Creates a click handler that toggles a RayTrace tab loop between +/// running and paused. +/// +/// # Arguments +/// +/// - `Signal` - The running signal of the active tab's loop. +/// +/// # Returns +/// +/// - `Option>` - The toggle handler. +pub(crate) fn raytrace_on_toggle_pause(running: Signal) -> Option> { + Some(Rc::new(move |_: Event| { + let current: bool = running.get(); + running.set(!current); + })) +} + +/// Creates a click handler that toggles camera auto-rotation. +/// +/// # Arguments +/// +/// - `Signal` - The auto-rotate signal of the active tab's loop. +/// +/// # Returns +/// +/// - `Option>` - The toggle handler. +pub(crate) fn raytrace_on_toggle_auto_rotate( + auto_rotate: Signal, +) -> Option> { + Some(Rc::new(move |_: Event| { + let current: bool = auto_rotate.get(); + auto_rotate.set(!current); + })) +} + +/// Creates a click handler that resets the camera orbit angles. +/// +/// # Arguments +/// +/// - `RayTraceCameraAngles` - The non-reactive camera orbit angles. +/// +/// # Returns +/// +/// - `Option>` - The toggle handler. +pub(crate) fn raytrace_on_reset_camera(angles: RayTraceCameraAngles) -> Option> { + Some(Rc::new(move |_: Event| { + angles.yaw.set(0.6); + angles.pitch.set(0.25); + })) +} + +/// Reads the client X coordinate off a `MouseEvent`-like object via +/// reflection. +/// +/// # Arguments +/// +/// - `&Event` - The pointer event. +/// +/// # Returns +/// +/// - `f64` - The client X coordinate, or `0.0` if missing. +fn client_x_from_event(event: &Event) -> f64 { + Reflect::get( + event.as_ref(), + &JsValue::from_str(RAYTRACE_EVENT_PROPERTY_CLIENT_X), + ) + .ok() + .and_then(|value: JsValue| value.as_f64()) + .unwrap_or_default() +} + +/// Reads the client Y coordinate off a `MouseEvent`-like object via +/// reflection. +/// /// # Arguments /// /// - `&Event` - The pointer event. @@ -966,13 +1998,33 @@ fn client_y_from_event(event: &Event) -> f64 { .unwrap_or_default() } +/// Applies a drag delta to the shared orbit angles and disables +/// auto-rotate. +/// +/// # Arguments +/// +/// - `f64` - The horizontal drag delta in CSS pixels. +/// - `f64` - The vertical drag delta in CSS pixels. +/// - `RayTraceCameraAngles` - The shared orbit angles. +/// - `Signal` - The auto-rotate signal to disable. +fn raytrace_apply_drag(dx: f64, dy: f64, angles: RayTraceCameraAngles, auto_rotate: Signal) { + let yaw: f64 = angles.yaw.get() - dx * RAYTRACE_DRAG_SENSITIVITY; + let pitch: f64 = (angles.pitch.get() + dy * RAYTRACE_DRAG_SENSITIVITY).clamp( + -FRAC_PI_2 + RAYTRACE_PITCH_CLAMP, + FRAC_PI_2 - RAYTRACE_PITCH_CLAMP, + ); + angles.yaw.set(yaw); + angles.pitch.set(pitch); + auto_rotate.set(false); +} + /// Creates a pointer move handler that updates the camera orbit angles /// from drag movement and disables auto-rotate for the rest of the /// session. /// /// # Arguments /// -/// - `RayTraceCameraAngles` - The non-reactive camera orbit angles. +/// - `RayTraceCameraAngles` - The shared orbit angles. /// - `Signal` - The auto-rotate signal of the active tab's loop. /// - `Rc>>` - The shared last pointer position cell. /// @@ -991,17 +2043,13 @@ pub(crate) fn raytrace_on_pointer_move( }; let client_x: f64 = client_x_from_event(&event); let client_y: f64 = client_y_from_event(&event); - let dx: f64 = client_x - last_x; - let dy: f64 = client_y - last_y; last_pointer.set(Some((client_x, client_y))); - let yaw: f64 = angles.yaw.get() - dx * RAYTRACE_DRAG_SENSITIVITY; - let pitch: f64 = (angles.pitch.get() + dy * RAYTRACE_DRAG_SENSITIVITY).clamp( - -FRAC_PI_2 + RAYTRACE_PITCH_CLAMP, - FRAC_PI_2 - RAYTRACE_PITCH_CLAMP, + raytrace_apply_drag( + client_x - last_x, + client_y - last_y, + angles.clone(), + auto_rotate, ); - angles.yaw.set(yaw); - angles.pitch.set(pitch); - auto_rotate.set(false); })) } @@ -1100,7 +2148,7 @@ pub(crate) fn raytrace_on_touch_start( /// /// # Arguments /// -/// - `RayTraceCameraAngles` - The non-reactive camera orbit angles. +/// - `RayTraceCameraAngles` - The shared orbit angles. /// - `Signal` - The auto-rotate signal of the active tab's loop. /// - `Rc>>` - The shared last pointer position cell. /// @@ -1121,17 +2169,13 @@ pub(crate) fn raytrace_on_touch_move( return; }; let (client_x, client_y): (f64, f64) = first_touch_client(&event); - let dx: f64 = client_x - last_x; - let dy: f64 = client_y - last_y; last_pointer.set(Some((client_x, client_y))); - let yaw: f64 = angles.yaw.get() - dx * RAYTRACE_DRAG_SENSITIVITY; - let pitch: f64 = (angles.pitch.get() + dy * RAYTRACE_DRAG_SENSITIVITY).clamp( - -FRAC_PI_2 + RAYTRACE_PITCH_CLAMP, - FRAC_PI_2 - RAYTRACE_PITCH_CLAMP, + raytrace_apply_drag( + client_x - last_x, + client_y - last_y, + angles.clone(), + auto_rotate, ); - angles.yaw.set(yaw); - angles.pitch.set(pitch); - auto_rotate.set(false); })) } @@ -1155,976 +2199,20 @@ pub(crate) fn raytrace_on_touch_end( })) } -/// Packs the engine's precomputed shadow bounding spheres into a -/// `vec4(center.xyz, radius)` array for the GPU shaders. -/// -/// Mirrors `RayTraceScene::shadow_points` term for term: the same four -/// `(center, radius)` pairs that the CPU `soft_shadow_factor` consumes, -/// but in the WebGL `u_sphere_packs` / WGSL `u_sphere_packs` layout the -/// shader needs. -/// -/// # Arguments -/// -/// - `f64` - The current camera yaw in radians. -/// -/// # Returns -/// -/// - `Vec` - The packed sphere-pack data (16 floats). -fn build_raytrace_sphere_pack_uniform(yaw: f64) -> Vec { - let (occluders, _): (Vec, Vector3D) = build_raytrace_scene(yaw); - let mut data: Vec = Vec::with_capacity(RAYTRACE_GPU_SPHERE_PACK_COUNT * 4); - // The engine precomputes shadow_points inside `RayTraceScene::new` - // but exposes the bounding spheres per-occluder via - // `Occluder::occluder_points`. The four occluders here are static - // sphere/AABB primitives, so each contributes one `(center, - // radius)` pair — exactly what `u_sphere_packs[4]` expects. - for occluder in occluders.iter() { - let points: Vec<(Vector3D, f64)> = occluder.occluder_points(); - if let Some(&(center, radius)) = points.first() { - data.push(center.get_x() as f32); - data.push(center.get_y() as f32); - data.push(center.get_z() as f32); - data.push(radius as f32); - } - } - // Pad short scene lists so the GPU still sees a 4-entry uniform - // array. Unreachable bounds => `soft_shadow_factor` returns 1.0, - // which is the correct "no occlusion" behavior. - while data.len() < RAYTRACE_GPU_SPHERE_PACK_COUNT * 4 { - data.push(0.0); - } - data -} - -/// Packs the per-frame uniform data consumed by the WebGL and WebGPU -/// raytrace shaders. -/// -/// Layout (10 `vec4` slots, matching `u_params[10]` / `SceneUniforms`): -/// orbit eye, camera forward, camera right, camera up, sun direction, -/// sun color, ambient, canvas resolution, sun screen position (the -/// sphere centre projected to NDC for the on-screen lamp, to make the -/// floor's lit pool visibly tied to the same point), and lamp anchor -/// (the floor-point under the sun where the lit pool converges). -/// -/// The eye used for specular shading inside the shaders is the fixed -/// `SHADE_EYE` constant, matching the CPU path. -/// -/// # Arguments -/// -/// - `f64` - The orbit yaw in radians. -/// - `f64` - The orbit pitch in radians. -/// - `f64` - The canvas backing width in physical pixels. -/// - `f64` - The canvas backing height in physical pixels. -/// - `Vector3D` - The sun position in world space (the sun direction -/// mirrored about the ground plane and scaled by -/// `RAYTRACE_SUN_DISTANCE`, i.e. [`raytrace_sun_position`]). -/// -/// # Returns -/// -/// - `Vec` - The packed uniform data (40 floats). -fn pack_raytrace_gpu_uniform( - yaw: f64, - pitch: f64, - width: f64, - height: f64, - sun_position: Vector3D, -) -> Vec { - let eye: Vector3D = compute_eye_position(yaw, pitch); - let (forward, right, up_true): (Vector3D, Vector3D, Vector3D) = - build_camera_basis(eye, yaw, pitch); - let sun_dir: Vector3D = raytrace_sun_direction(yaw); - let mut data: Vec = Vec::with_capacity(RAYTRACE_GPU_UNIFORM_VEC4_COUNT * 4); - let vectors: [Vector3D; 5] = [eye, forward, right, up_true, sun_dir]; - for vector in vectors { - data.push(vector.get_x() as f32); - data.push(vector.get_y() as f32); - data.push(vector.get_z() as f32); - data.push(0.0); - } - data.extend_from_slice(&[1.0, 0.95, 0.85, 0.0]); - data.extend_from_slice(&[0.10, 0.10, 0.14, 0.0]); - data.extend_from_slice(&[width as f32, height as f32, 0.0, 0.0]); - // Sun world position, projected: the shader uses `sun_dir * - // SUN_DISTANCE` for its ray hits, but the floor's lit pool comes - // from the Lambert term `dot(normal, light_dir)` evaluated in - // world space. Uploading the resolved `sun_position` here too - // lets the renderer cross-check the two stay in lock-step. - data.extend_from_slice(&[ - sun_position.get_x() as f32, - sun_position.get_y() as f32, - sun_position.get_z() as f32, - 0.0, - ]); - // Lamp anchor: the floor-point directly under the sun where the - // lit pool converges (the Lambert term reaches its peak). Kept as - // a uniform so any future shader-side debug visualisation stays - // bound to the same coordinate the eye sees on screen. - data.extend_from_slice(&[ - sun_position.get_x() as f32, - GROUND_Y_TOP_FLOOR_LAMP as f32, - sun_position.get_z() as f32, - 0.0, - ]); - data -} - -/// Sets the Canvas 2D `loaded` signal after a short delay so the -/// loading overlay is actually painted before it is removed. -/// -/// Mirrors [`raytrace_set_loaded_delayed`] (used by the WebGL / WebGPU -/// tabs) and shares its `GAME_3D_LOADING_MIN_MILLIS` floor so the -/// overlay stays visible for a minimum duration even on devices where -/// the first frame finishes inside the same `requestAnimationFrame` -/// tick that the overlay is mounted on. Reusing the same floor keeps -/// all three RayTrace tabs visually consistent: the user always sees -/// the "Initializing..." text for the same wall-clock window no -/// matter which tab they boot the page on or switch into. -/// -/// # Arguments -/// -/// - `Signal` - The Canvas 2D `loaded` signal to set. -/// - `i32` - The delay in milliseconds before setting the signal. -fn raytrace_set_loaded_delayed_canvas2d(loaded: Signal, millis: i32) { - let loaded_closure: Closure = Closure::wrap(Box::new(move || { - loaded.set(true); - })); - let loaded_callback: Function = loaded_closure.as_ref().unchecked_ref::().clone(); - loaded_closure.forget(); - let Some(loaded_window): Option = window() else { - return; - }; - let _: Result = loaded_window - .set_timeout_with_callback_and_timeout_and_arguments_0(&loaded_callback, millis); -} - -/// Sets the backend `loaded` signal after a short delay so the loading -/// overlay is actually painted before it is removed. -/// -/// Synchronous WebGL init (and fast WebGPU init) would otherwise add and -/// remove the overlay canvas within a single frame, so the browser never -/// paints the loading state when switching tabs. -/// -/// # Arguments -/// -/// - `Signal` - The backend `loaded` signal to set. -/// - `i32` - The delay in milliseconds before setting the signal. -fn raytrace_set_loaded_delayed(loaded: Signal, millis: i32) { - let loaded_closure: Closure = Closure::wrap(Box::new(move || { - loaded.set(true); - })); - let loaded_callback: Function = loaded_closure.as_ref().unchecked_ref::().clone(); - loaded_closure.forget(); - let Some(loaded_window): Option = window() else { - return; - }; - let _: Result = loaded_window - .set_timeout_with_callback_and_timeout_and_arguments_0(&loaded_callback, millis); -} - -/// Registers the debounced window-resize listener shared by the WebGL -/// and WebGPU raytrace loops. -/// -/// # Arguments -/// -/// - `Rc>` - The resize-dirty flag set after the debounce fires. -/// - `Rc>>` - The pending debounce timer handle. -fn raytrace_register_resize_debounce( - resize_dirty: Rc>, - resize_timer: Rc>>, -) { - let resize_dirty_for_event: Rc> = resize_dirty.clone(); - let resize_timer_for_event: Rc>> = resize_timer.clone(); - let debounce_closure: Closure = Closure::wrap(Box::new(move || { - resize_dirty_for_event.set(true); - })); - let debounce_callback: Function = debounce_closure - .as_ref() - .unchecked_ref::() - .clone(); - debounce_closure.forget(); - let Some(resize_window): Option = window() else { - return; - }; - App::use_window_event(RAYTRACE_EVENT_RESIZE, move || { - let old_timer: Option = resize_timer_for_event.get(); - if let Some(timer_id) = old_timer { - let Some(clear_window): Option = window() else { - return; - }; - clear_window.clear_timeout_with_handle(timer_id); - } - let new_timer: i32 = resize_window - .set_timeout_with_callback_and_timeout_and_arguments_0( - &debounce_callback, - GAME_3D_RESIZE_DEBOUNCE_MILLIS, - ) - .unwrap_or_default(); - resize_timer_for_event.set(Some(new_timer)); - }); -} - -/// Starts the RayTrace WebGL loop driven by `requestAnimationFrame`. -/// -/// Renders the same scene as the Canvas 2D tab through a GLSL ES 3.00 -/// fragment shader on a fullscreen triangle. The canvas backing store -/// tracks the CSS box times the device pixel ratio (synchronous -/// ResizeObserver plus a debounced window-resize flag plus a per-frame -/// divergence check, mirroring the 3D game page), and the shader -/// aspect-corrects the NDC via the resolution uniform so geometry never -/// stretches. WebGL initialization is synchronous; the `spawn_local` -/// wrapper only defers execution past the current render pass so the -/// canvas element exists in the DOM. -/// -/// # Arguments -/// -/// - `UseRayTraceWebGl` - The WebGL backend state for signal updates. -/// - `RayTraceCameraAngles` - The shared non-reactive camera orbit angles. -pub(crate) fn start_raytrace_webgl_loop(state: UseRayTraceWebGl, angles: RayTraceCameraAngles) { - let init_state: UseRayTraceWebGl = state; - let loop_state: UseRayTraceWebGl = state; - let raf_id: Rc>> = Rc::new(Cell::new(None)); - let closure_cell: RafClosureCell = Rc::new(MaybeEngineCell::new()); - let resize_dirty: Rc> = Rc::new(Cell::new(false)); - let resize_timer: Rc>> = Rc::new(Cell::new(None)); - let renderer_rc: Rc>> = Rc::new(RefCell::new(None)); - let cancelled: Rc> = Rc::new(Cell::new(false)); - let observer_cell: Rc>> = Rc::new(RefCell::new(None)); - raytrace_register_resize_debounce(resize_dirty.clone(), resize_timer.clone()); - let raf_for_cleanup: Rc>> = raf_id.clone(); - let cell_for_cleanup: RafClosureCell = closure_cell.clone(); - let renderer_for_cleanup: Rc>> = renderer_rc.clone(); - let resize_timer_for_cleanup: Rc>> = resize_timer.clone(); - let cancelled_for_cleanup: Rc> = cancelled.clone(); - let observer_for_cleanup: Rc>> = observer_cell.clone(); - App::use_cleanup(move || { - cancelled_for_cleanup.set(true); - // Every step is independent: a missing `window()` must never skip - // the renderer teardown below, so no early returns here. - if let Some(cancel_id) = raf_for_cleanup.get() - && let Some(window_value) = window() - { - let _: Result<(), JsValue> = window_value.cancel_animation_frame(cancel_id); - } - if let Some(timer_id) = resize_timer_for_cleanup.get() - && let Some(window_value) = window() - { - window_value.clear_timeout_with_handle(timer_id); - } - // Disconnect the ResizeObserver so its closure (and the renderer - // it holds via `renderer_for_observer`) is released on tab - // switch. Without this the observer keeps the renderer alive - // past the loop's lifetime, holding GPU resources until GC. - if let Some(observer) = observer_for_cleanup.borrow_mut().take() { - observer.disconnect(); - } - let _: Option<_> = cell_for_cleanup.try_take(); - // WebGL has no explicit `destroy()` on the context: dropping the - // last JS reference lets the browser GC reclaim the GL context. - let _: Option = renderer_for_cleanup.borrow_mut().take(); - }); - let cancelled_for_init: Rc> = cancelled.clone(); - let Some(loading_window): Option = window() else { - return; - }; - let loading_closure: Closure = Closure::wrap(Box::new(move || { - draw_game_3d_loading( - RAYTRACE_WEBGL_LOADING_CANVAS_SELECTOR, - RAYTRACE_WEBGL_CANVAS_SELECTOR, - ); - })); - let loading_callback: Function = loading_closure.as_ref().unchecked_ref::().clone(); - loading_closure.forget(); - let _: Result = - loading_window.set_timeout_with_callback_and_timeout_and_arguments_0(&loading_callback, 0); - spawn_local(async move { - if cancelled_for_init.get() { - return; - } - let config: RenderConfig = RenderConfig::webgl( - RAYTRACE_WEBGL_CANVAS_SELECTOR, - RAYTRACE_WIDTH, - RAYTRACE_HEIGHT, - ); - let renderer: WebGl2Backend = match Engine::webgl_renderer(&config) { - Ok(value) => value, - Err(error) => { - Console::error(format!("[euv-engine][raytrace] webgl init failed: {error}")); - init_state.get_init_error_code().set(error.code()); - init_state.get_loaded().set(true); - return; - } - }; - let init_context: WebGl2RenderingContext = renderer.get_context().clone(); - let program: GlProgram = match GlProgram::create( - &init_context, - RAYTRACE_WEBGL_VERTEX_SHADER, - RAYTRACE_WEBGL_FRAGMENT_SHADER, - ) { - Ok(value) => value, - Err(error) => { - Console::error(format!( - "[euv-engine][raytrace] webgl program failed: {error}" - )); - init_state.get_init_error_code().set("WEBGL_PROGRAM_ERROR"); - init_state.get_loaded().set(true); - return; - } - }; - let clear_color: Rc> = Rc::new(Cell::new( - game_3d_canvas_clear_color(RAYTRACE_WEBGL_CANVAS_SELECTOR), - )); - init_state.get_active().set(true); - // Delay flipping `loaded` so the loading overlay stays painted for a - // minimum visible duration even when init completes instantly. - raytrace_set_loaded_delayed(init_state.get_loaded(), GAME_3D_LOADING_MIN_MILLIS); - *renderer_rc.borrow_mut() = Some(renderer); - // `GlProgram` owns its own name-keyed uniform-location cache, so the - // program itself is shared as `RefCell`: `set_uniform_*` needs - // `&mut GlProgram`, and a `WebGlProgram` handle alone cannot carry - // the cache the old `Rc` did. - let program_rc: Rc> = Rc::new(RefCell::new(program)); - // Synchronous resize on CSS-box change. ResizeObserver callbacks - // run BEFORE the browser paints the next frame, so setting - // `canvas.width = new_w` inside the observer ensures the very - // first paint after fullscreen enter/exit already has the new - // backing store instead of one stretched frame of the old - // backing. `canvas.width` is applied BEFORE `renderer.resize` - // because the DOM setter is fast while the GL-side realloc can - // stall the main thread. - let renderer_for_observer: Rc>> = renderer_rc.clone(); - let observer_closure: Closure = Closure::wrap( - Box::new(move |_entries: js_sys::Array, _obs: ResizeObserver| { - let Some(window_value): Option = window() else { - return; - }; - let Some(document_value): Option = window_value.document() else { - return; - }; - let Some(element): Option = document_value - .query_selector(RAYTRACE_WEBGL_CANVAS_SELECTOR) - .ok() - .flatten() - else { - return; - }; - let canvas: HtmlCanvasElement = element.unchecked_into(); - let rect: DomRect = canvas.get_bounding_client_rect(); - let css_w: f64 = rect.width(); - let css_h: f64 = rect.height(); - if css_w <= 0.0 || css_h <= 0.0 { - return; - } - let dpr: f64 = Reflect::get( - window_value.as_ref(), - &JsValue::from_str(RAYTRACE_EVENT_PROPERTY_DEVICE_PIXEL_RATIO), - ) - .ok() - .and_then(|v: JsValue| v.as_f64()) - .filter(|v: &f64| v.is_finite() && *v >= 1.0) - .unwrap_or(1.0); - let new_w: u32 = (css_w * dpr).round() as u32; - let new_h: u32 = (css_h * dpr).round() as u32; - let backing_w: u32 = canvas.width(); - let backing_h: u32 = canvas.height(); - if backing_w != new_w || backing_h != new_h { - canvas.set_width(new_w); - canvas.set_height(new_h); - if let Some(renderer) = renderer_for_observer.borrow_mut().as_mut() { - renderer.resize(new_w, new_h); - } - } - }), - ); - let observer_callback: Function = observer_closure - .as_ref() - .unchecked_ref::() - .clone(); - observer_closure.forget(); - if let Ok(resize_observer) = ResizeObserver::new(&observer_callback) - && let Some(window_value) = window() - && let Some(document_value) = window_value.document() - && let Some(element) = document_value - .query_selector(RAYTRACE_WEBGL_CANVAS_SELECTOR) - .ok() - .flatten() - { - resize_observer.observe(&element); - // `try_borrow_mut`, not `borrow_mut`: the tab-switch cleanup - // registered above also mutably borrows this same cell from - // `HookContext::switch_arm`, and this backend's init is - // asynchronous (WebGPU awaits adapter / device promises, WebGL - // defers behind `spawn_local`), so the arm can switch while - // init is still in flight and the two borrows genuinely - // overlap. That collision is not theoretical: it reproduced - // as `already borrowed: BorrowMutError` in - // `RefCell>::borrow_mut`, and a - // BorrowMutError in WASM aborts the whole app with no - // catchable panic. On contention the observer is dropped here - // and the loop runs without live resize notifications — the - // per-frame backing-store divergence check still resizes. - if let Ok(mut observer_slot) = observer_cell.try_borrow_mut() { - *observer_slot = Some(resize_observer); - } - } - let last_time: Rc> = Rc::new(Cell::new(-1.0)); - let frame_count: Rc> = Rc::new(Cell::new(0)); - let fps_timer: Rc> = Rc::new(Cell::new(0.0)); - let renderer_for_loop: Rc>> = renderer_rc.clone(); - let program_for_loop: Rc> = program_rc.clone(); - let clear_color_for_loop: Rc> = clear_color.clone(); - let yaw_for_loop: Rc> = angles.yaw.clone(); - let pitch_for_loop: Rc> = angles.pitch.clone(); - let raf_clone: Rc>> = raf_id.clone(); - let cell_clone: RafClosureCell = closure_cell.clone(); - let last_clone: Rc> = last_time.clone(); - let frame_clone: Rc> = frame_count.clone(); - let fps_clone: Rc> = fps_timer.clone(); - let resize_dirty_for_loop: Rc> = resize_dirty.clone(); - let cancelled_for_loop: Rc> = cancelled.clone(); - let raf_closure: Closure = Closure::wrap(Box::new(move || { - // Stop on tab-switch cleanup (`cancelled`) or when the canvas - // left the document (router navigation fires no cleanup). - if cancelled_for_loop.get() || raytrace_canvas_detached(RAYTRACE_WEBGL_CANVAS_SELECTOR) - { - return; - } - let Some(window_value): Option = window() else { - return; - }; - let Some(performance): Option = window_value.performance() else { - return; - }; - let current_time: f64 = performance.now() / 1000.0; - let prev: f64 = last_clone.get(); - // Unclamped frame time feeds the FPS counter (honest rate); - // the clamped delta only drives the yaw animation step. - let frame_time: f64 = if prev < 0.0 { - 1.0 / 60.0 - } else { - current_time - prev - }; - let anim_time: f64 = frame_time.min(0.25); - last_clone.set(current_time); - if loop_state.get_auto_rotate().get() { - yaw_for_loop.set(yaw_for_loop.get() + RAYTRACE_AUTO_YAW_SPEED * anim_time); - } - let resize_dirty_frame: bool = if resize_dirty_for_loop.get() { - resize_dirty_for_loop.set(false); - true - } else { - false - }; - let Some(window_for_dpr): Option = window() else { - return; - }; - let dpr: f64 = Reflect::get( - window_for_dpr.as_ref(), - &JsValue::from_str(RAYTRACE_EVENT_PROPERTY_DEVICE_PIXEL_RATIO), - ) - .ok() - .and_then(|value: JsValue| value.as_f64()) - .filter(|value: &f64| value.is_finite() && *value >= 1.0) - .unwrap_or(1.0); - // Read the canvas's CSS pixel dimensions via - // `getBoundingClientRect` (NOT `clientWidth`/`clientHeight` - - // the latter track the backing store in Chrome and would - // create a feedback loop if read every frame). - let (canvas_width, canvas_height): (f64, f64) = - read_raytrace_canvas_size(RAYTRACE_WEBGL_CANVAS_SELECTOR).unwrap_or((0.0, 0.0)); - let new_physical_width: u32 = (canvas_width * dpr).round() as u32; - let new_physical_height: u32 = (canvas_height * dpr).round() as u32; - if let Some(renderer) = renderer_for_loop.borrow_mut().as_mut() { - // `set_uniform_*` and `render_frame` both take `&mut self`, - // while the context they must draw through is borrowed out - // of that same `self`. Clone the handle instead: it is a JS - // object reference, so the clone only bumps a refcount and - // leaves both the borrow of `renderer` and the GL context - // valid for the duration of the frame. - let context: WebGl2RenderingContext = renderer.get_context().clone(); - // Resize the WebGL backing store every frame the CSS box - // diverges from `canvas.width` / `canvas.height`. The - // per-frame check collapses the stretched-frame window - // after fullscreen transitions to a single frame; the - // ResizeObserver path normally beats it to zero. - if new_physical_width > 0 && new_physical_height > 0 { - let backing_w: u32 = renderer.get_canvas().width(); - let backing_h: u32 = renderer.get_canvas().height(); - if backing_w != new_physical_width || backing_h != new_physical_height { - renderer.get_canvas().set_width(new_physical_width); - renderer.get_canvas().set_height(new_physical_height); - renderer.resize(new_physical_width, new_physical_height); - } - } - if resize_dirty_frame { - renderer.resize(new_physical_width, new_physical_height); - } - if loop_state.get_running().get() { - let yaw: f64 = yaw_for_loop.get(); - let pitch: f64 = pitch_for_loop.get(); - let backing_w: f64 = f64::from(renderer.get_canvas().width()); - let backing_h: f64 = f64::from(renderer.get_canvas().height()); - let sun_position: Vector3D = raytrace_sun_position(yaw); - let uniform_data: Vec = - pack_raytrace_gpu_uniform(yaw, pitch, backing_w, backing_h, sun_position); - // Upload the engine's precomputed shadow bounding - // spheres. Mirrors the WGSL `u_sphere_packs` binding - // so the GLSL path's `soft_shadow_factor` agrees - // with the CPU `RayTraceScene::shadow_points`. - let sphere_data: Vec = build_raytrace_sphere_pack_uniform(yaw); - { - let mut program_borrow: RefMut<'_, GlProgram> = - program_for_loop.borrow_mut(); - program_borrow.set_uniform_vec4_array( - &context, - "u_params[0]", - &uniform_data, - ); - program_borrow.set_uniform_vec4_array( - &context, - "u_sphere_packs[0]", - &sphere_data, - ); - } - // Refresh the clear color every frame so a theme - // toggle takes effect within one paint. - let next_clear: (f64, f64, f64) = - game_3d_canvas_clear_color(RAYTRACE_WEBGL_CANVAS_SELECTOR); - if clear_color_for_loop.get() != next_clear { - clear_color_for_loop.set(next_clear); - } - let (r, g, b) = clear_color_for_loop.get(); - renderer.render_frame( - &context, - &program_for_loop.borrow(), - Color::new(r, g, b, 1.0), - 3, - ); - } - } - frame_clone.set(frame_clone.get() + 1); - fps_clone.set(fps_clone.get() + frame_time); - if fps_clone.get() >= 1.0 { - let fps: f64 = f64::from(frame_clone.get()) / fps_clone.get(); - loop_state.get_fps().set(fps); - frame_clone.set(0); - fps_clone.set(0.0); - } - let Some(raf_closure_ref): Option<&'static Closure> = cell_clone.try_get() - else { - return; - }; - let next_id: i32 = window_value - .request_animation_frame(raf_closure_ref.as_ref().unchecked_ref()) - .unwrap_or_default(); - if cancelled_for_loop.get() { - raf_clone.set(None); - } else { - raf_clone.set(Some(next_id)); - } - })); - let _: Result<(), _> = closure_cell.try_set(raf_closure); - let Some(start_window): Option = window() else { - return; - }; - let Some(start_raf_ref): Option<&'static Closure> = closure_cell.try_get() - else { - return; - }; - let start_id: i32 = start_window - .request_animation_frame(start_raf_ref.as_ref().unchecked_ref()) - .unwrap_or_default(); - raf_id.set(Some(start_id)); - }); -} - -/// Starts the RayTrace WebGPU loop driven by `requestAnimationFrame`. -/// -/// Renders the same scene as the Canvas 2D tab through a WGSL fragment -/// shader on a fullscreen triangle, fed by a single 8-`vec4` uniform -/// buffer at `@group(0) @binding(0)` updated once per frame. WebGPU -/// initialization is asynchronous (adapter + device promises raced -/// against a timeout inside the engine), so the whole init runs inside -/// `spawn_local` with a cancellation guard for tab switches. On failure -/// the error code is surfaced to the status banner and the loop exits -/// quietly. -/// -/// # Arguments -/// -/// - `UseRayTraceWebGpu` - The WebGPU backend state for signal updates. -/// - `RayTraceCameraAngles` - The shared non-reactive camera orbit angles. -pub(crate) fn start_raytrace_webgpu_loop(state: UseRayTraceWebGpu, angles: RayTraceCameraAngles) { - let init_state: UseRayTraceWebGpu = state; - let loop_state: UseRayTraceWebGpu = state; - let raf_id: Rc>> = Rc::new(Cell::new(None)); - let closure_cell: RafClosureCell = Rc::new(MaybeEngineCell::new()); - let resize_dirty: Rc> = Rc::new(Cell::new(false)); - let resize_timer: Rc>> = Rc::new(Cell::new(None)); - let renderer_rc: Rc>> = Rc::new(RefCell::new(None)); - let cancelled: Rc> = Rc::new(Cell::new(false)); - let observer_cell: Rc>> = Rc::new(RefCell::new(None)); - raytrace_register_resize_debounce(resize_dirty.clone(), resize_timer.clone()); - let raf_for_cleanup: Rc>> = raf_id.clone(); - let cell_for_cleanup: RafClosureCell = closure_cell.clone(); - let renderer_for_cleanup: Rc>> = renderer_rc.clone(); - let resize_timer_for_cleanup: Rc>> = resize_timer.clone(); - let cancelled_for_cleanup: Rc> = cancelled.clone(); - let observer_for_cleanup: Rc>> = observer_cell.clone(); - App::use_cleanup(move || { - cancelled_for_cleanup.set(true); - // Every step is independent: a missing `window()` must never skip - // the renderer teardown below, so no early returns here. - if let Some(cancel_id) = raf_for_cleanup.get() - && let Some(window_value) = window() - { - let _: Result<(), JsValue> = window_value.cancel_animation_frame(cancel_id); - } - if let Some(timer_id) = resize_timer_for_cleanup.get() - && let Some(window_value) = window() - { - window_value.clear_timeout_with_handle(timer_id); - } - // Disconnect the ResizeObserver so its closure (and the renderer - // it holds via `renderer_for_observer`) is released on tab - // switch. Without this the observer keeps the renderer alive - // past the loop's lifetime, holding GPU resources until GC. - if let Some(observer) = observer_for_cleanup.borrow_mut().take() { - observer.disconnect(); - } - let _: Option<_> = cell_for_cleanup.try_take(); - // Release GPU resources before dropping the renderer so the - // device and swap chain are freed eagerly. Without this the - // old GPU device can linger until GC, causing a fresh - // WebGpuRenderer::init() either to reuse the dead device - // (silent black canvas) or to fail to acquire a new one. - if let Some(renderer) = renderer_for_cleanup.borrow_mut().take() { - renderer.dispose(); - } - }); - let cancelled_for_init: Rc> = cancelled.clone(); - let Some(loading_window): Option = window() else { - return; - }; - let loading_closure: Closure = Closure::wrap(Box::new(move || { - draw_game_3d_loading( - RAYTRACE_WEBGPU_LOADING_CANVAS_SELECTOR, - RAYTRACE_WEBGPU_CANVAS_SELECTOR, - ); - })); - let loading_callback: Function = loading_closure.as_ref().unchecked_ref::().clone(); - loading_closure.forget(); - let _: Result = - loading_window.set_timeout_with_callback_and_timeout_and_arguments_0(&loading_callback, 0); - spawn_local(async move { - let config: RenderConfig = RenderConfig::webgpu( - RAYTRACE_WEBGPU_CANVAS_SELECTOR, - RAYTRACE_WIDTH, - RAYTRACE_HEIGHT, - ); - let renderer: Result = - Engine::webgpu_renderer(&config).await; - if cancelled_for_init.get() { - // The tab was switched away while the adapter / device promises - // were in flight. A successfully-created renderer still owns a - // live GPU device at this point; destroy it eagerly instead of - // leaving it for GC to discover. - if let Ok(stale_renderer) = &renderer { - stale_renderer.dispose(); - } - return; - } - let renderer: WebGpuRenderer = match renderer { - Ok(value) => value, - Err(error) => { - Console::error(format!( - "[euv-engine][raytrace] webgpu init failed: {error}" - )); - init_state.get_init_error_code().set(error.code()); - init_state.get_loaded().set(true); - return; - } - }; - let pipeline: JsValue = renderer.create_render_pipeline(RAYTRACE_WEBGPU_SHADER); - let uniform_buffer: JsValue = - renderer.create_uniform_buffer(&[0.0; RAYTRACE_GPU_UNIFORM_VEC4_COUNT * 4]); - // Precomputed shadow bounding spheres for the WGSL - // `soft_shadow_factor` mirroring `RayTraceScene::shadow_points`. - let sphere_pack_buffer: JsValue = - renderer.create_uniform_buffer(&[0.0; RAYTRACE_GPU_SPHERE_PACK_COUNT * 4]); - // The WebGPU shader binds the per-frame uniforms at - // `@group(0) @binding(0)` and the sphere packs at - // `@group(0) @binding(1)`, so the bind group needs both buffers - // in entry order. The engine exposes the multi-buffer - // constructor as `create_bind_group`; we reuse it here so we - // do not need a wrapper. - let bind_group: JsValue = renderer.create_bind_group( - &pipeline, - 0, - &[ - BindGroupEntry::Buffer { - binding: 0, - buffer: uniform_buffer.clone(), - offset: 0, - size: None, - }, - BindGroupEntry::Buffer { - binding: 1, - buffer: sphere_pack_buffer.clone(), - offset: 0, - size: None, - }, - ], - ); - let clear_color: Rc> = Rc::new(Cell::new( - game_3d_canvas_clear_color(RAYTRACE_WEBGPU_CANVAS_SELECTOR), - )); - init_state.get_active().set(true); - // Delay flipping `loaded` so the loading overlay stays painted for a - // minimum visible duration even when init completes instantly. - raytrace_set_loaded_delayed(init_state.get_loaded(), GAME_3D_LOADING_MIN_MILLIS); - *renderer_rc.borrow_mut() = Some(renderer); - let pipeline_rc: Rc = Rc::new(pipeline); - let buffer_rc: Rc = Rc::new(uniform_buffer); - let sphere_pack_buffer_rc: Rc = Rc::new(sphere_pack_buffer); - let bind_group_rc: Rc = Rc::new(bind_group); - // Synchronous resize on CSS-box change; `canvas.width` is applied - // BEFORE `renderer.resize(...)` so the first paint after a - // fullscreen transition already has a correctly-sized backing - // store (mirrors the 3D game page's WebGPU tab). - let renderer_for_observer: Rc>> = renderer_rc.clone(); - let observer_closure: Closure = Closure::wrap( - Box::new(move |_entries: js_sys::Array, _obs: ResizeObserver| { - let Some(window_value): Option = window() else { - return; - }; - let Some(document_value): Option = window_value.document() else { - return; - }; - let Some(element): Option = document_value - .query_selector(RAYTRACE_WEBGPU_CANVAS_SELECTOR) - .ok() - .flatten() - else { - return; - }; - let canvas: HtmlCanvasElement = element.unchecked_into(); - let rect: DomRect = canvas.get_bounding_client_rect(); - let css_w: f64 = rect.width(); - let css_h: f64 = rect.height(); - if css_w <= 0.0 || css_h <= 0.0 { - return; - } - let dpr: f64 = Reflect::get( - window_value.as_ref(), - &JsValue::from_str(RAYTRACE_EVENT_PROPERTY_DEVICE_PIXEL_RATIO), - ) - .ok() - .and_then(|v: JsValue| v.as_f64()) - .filter(|v: &f64| v.is_finite() && *v >= 1.0) - .unwrap_or(1.0); - let new_w: u32 = (css_w * dpr).round() as u32; - let new_h: u32 = (css_h * dpr).round() as u32; - let backing_w: u32 = canvas.width(); - let backing_h: u32 = canvas.height(); - if backing_w != new_w || backing_h != new_h { - canvas.set_width(new_w); - canvas.set_height(new_h); - if let Some(renderer) = renderer_for_observer.borrow_mut().as_mut() { - renderer.resize(new_w, new_h); - } - } - }), - ); - let observer_callback: Function = observer_closure - .as_ref() - .unchecked_ref::() - .clone(); - observer_closure.forget(); - if let Ok(resize_observer) = ResizeObserver::new(&observer_callback) - && let Some(window_value) = window() - && let Some(document_value) = window_value.document() - && let Some(element) = document_value - .query_selector(RAYTRACE_WEBGPU_CANVAS_SELECTOR) - .ok() - .flatten() - { - resize_observer.observe(&element); - // `try_borrow_mut`, not `borrow_mut`: the tab-switch cleanup - // registered above also mutably borrows this same cell from - // `HookContext::switch_arm`, and this backend's init is - // asynchronous (WebGPU awaits adapter / device promises, WebGL - // defers behind `spawn_local`), so the arm can switch while - // init is still in flight and the two borrows genuinely - // overlap. That collision is not theoretical: it reproduced - // as `already borrowed: BorrowMutError` in - // `RefCell>::borrow_mut`, and a - // BorrowMutError in WASM aborts the whole app with no - // catchable panic. On contention the observer is dropped here - // and the loop runs without live resize notifications — the - // per-frame backing-store divergence check still resizes. - if let Ok(mut observer_slot) = observer_cell.try_borrow_mut() { - *observer_slot = Some(resize_observer); - } - } - let last_time: Rc> = Rc::new(Cell::new(-1.0)); - let frame_count: Rc> = Rc::new(Cell::new(0)); - let fps_timer: Rc> = Rc::new(Cell::new(0.0)); - let renderer_for_loop: Rc>> = renderer_rc.clone(); - let pipeline_for_loop: Rc = pipeline_rc.clone(); - let buffer_for_loop: Rc = buffer_rc.clone(); - let sphere_pack_buffer_for_loop: Rc = sphere_pack_buffer_rc.clone(); - let bind_group_for_loop: Rc = bind_group_rc.clone(); - let clear_color_for_loop: Rc> = clear_color.clone(); - let yaw_for_loop: Rc> = angles.yaw.clone(); - let pitch_for_loop: Rc> = angles.pitch.clone(); - let raf_clone: Rc>> = raf_id.clone(); - let cell_clone: RafClosureCell = closure_cell.clone(); - let last_clone: Rc> = last_time.clone(); - let frame_clone: Rc> = frame_count.clone(); - let fps_clone: Rc> = fps_timer.clone(); - let resize_dirty_for_loop: Rc> = resize_dirty.clone(); - let cancelled_for_loop: Rc> = cancelled.clone(); - let raf_closure: Closure = Closure::wrap(Box::new(move || { - // Stop on tab-switch cleanup (`cancelled`) or when the canvas - // left the document (router navigation fires no cleanup). - if cancelled_for_loop.get() || raytrace_canvas_detached(RAYTRACE_WEBGPU_CANVAS_SELECTOR) - { - return; - } - let Some(window_value): Option = window() else { - return; - }; - let Some(performance): Option = window_value.performance() else { - return; - }; - let current_time: f64 = performance.now() / 1000.0; - let prev: f64 = last_clone.get(); - // Unclamped frame time feeds the FPS counter (honest rate); - // the clamped delta only drives the yaw animation step. - let frame_time: f64 = if prev < 0.0 { - 1.0 / 60.0 - } else { - current_time - prev - }; - let anim_time: f64 = frame_time.min(0.25); - last_clone.set(current_time); - if loop_state.get_auto_rotate().get() { - yaw_for_loop.set(yaw_for_loop.get() + RAYTRACE_AUTO_YAW_SPEED * anim_time); - } - let resize_dirty_frame: bool = if resize_dirty_for_loop.get() { - resize_dirty_for_loop.set(false); - true - } else { - false - }; - let Some(window_for_dpr): Option = window() else { - return; - }; - let dpr: f64 = Reflect::get( - window_for_dpr.as_ref(), - &JsValue::from_str(RAYTRACE_EVENT_PROPERTY_DEVICE_PIXEL_RATIO), - ) - .ok() - .and_then(|value: JsValue| value.as_f64()) - .filter(|value: &f64| value.is_finite() && *value >= 1.0) - .unwrap_or(1.0); - let (canvas_width, canvas_height): (f64, f64) = - read_raytrace_canvas_size(RAYTRACE_WEBGPU_CANVAS_SELECTOR).unwrap_or((0.0, 0.0)); - let new_physical_width: u32 = (canvas_width * dpr).round() as u32; - let new_physical_height: u32 = (canvas_height * dpr).round() as u32; - // Borrow the renderer exactly once for the entire frame via - // `borrow_mut().as_mut()` so the RefMut guard releases - // automatically when this block exits, avoiding the - // `RefCell already borrowed` panic a second borrow would hit. - if let Some(renderer) = renderer_for_loop.borrow_mut().as_mut() { - if new_physical_width > 0 && new_physical_height > 0 { - let backing_w: u32 = renderer.get_canvas().width(); - let backing_h: u32 = renderer.get_canvas().height(); - if backing_w != new_physical_width || backing_h != new_physical_height { - renderer.get_canvas().set_width(new_physical_width); - renderer.get_canvas().set_height(new_physical_height); - let _: bool = renderer.resize(new_physical_width, new_physical_height); - } - } - if resize_dirty_frame { - let _: bool = renderer.resize(new_physical_width, new_physical_height); - } - if loop_state.get_running().get() { - let yaw: f64 = yaw_for_loop.get(); - let pitch: f64 = pitch_for_loop.get(); - let backing_w: f64 = f64::from(renderer.get_canvas().width()); - let backing_h: f64 = f64::from(renderer.get_canvas().height()); - let sun_position: Vector3D = raytrace_sun_position(yaw); - let uniform_data: Vec = - pack_raytrace_gpu_uniform(yaw, pitch, backing_w, backing_h, sun_position); - renderer.update_uniform_buffer(&buffer_for_loop, &uniform_data); - let sphere_data: Vec = build_raytrace_sphere_pack_uniform(yaw); - renderer.update_uniform_buffer(&sphere_pack_buffer_for_loop, &sphere_data); - // Refresh the clear color every frame so a theme - // toggle takes effect within one paint. - let next_clear: (f64, f64, f64) = - game_3d_canvas_clear_color(RAYTRACE_WEBGPU_CANVAS_SELECTOR); - if clear_color_for_loop.get() != next_clear { - clear_color_for_loop.set(next_clear); - } - let (r, g, b) = clear_color_for_loop.get(); - renderer.render_frame_with_bind_group( - &pipeline_for_loop, - &bind_group_for_loop, - Color::new(r, g, b, 1.0), - 3, - ); - } - } - frame_clone.set(frame_clone.get() + 1); - fps_clone.set(fps_clone.get() + frame_time); - if fps_clone.get() >= 1.0 { - let fps: f64 = f64::from(frame_clone.get()) / fps_clone.get(); - loop_state.get_fps().set(fps); - frame_clone.set(0); - fps_clone.set(0.0); - } - let Some(raf_closure_ref): Option<&'static Closure> = cell_clone.try_get() - else { - return; - }; - let next_id: i32 = window_value - .request_animation_frame(raf_closure_ref.as_ref().unchecked_ref()) - .unwrap_or_default(); - if cancelled_for_loop.get() { - raf_clone.set(None); - } else { - raf_clone.set(Some(next_id)); - } - })); - let _: Result<(), _> = closure_cell.try_set(raf_closure); - let Some(start_window): Option = window() else { - return; - }; - let Some(start_raf_ref): Option<&'static Closure> = closure_cell.try_get() - else { - return; - }; - let start_id: i32 = start_window - .request_animation_frame(start_raf_ref.as_ref().unchecked_ref()) - .unwrap_or_default(); - raf_id.set(Some(start_id)); - }); -} - /// Creates a click event handler that sets the active tab and exits /// any in-flight landscape fullscreen mode before switching. /// /// Tab switches destroy the previous arm's DOM subtree (the match /// expression rebuilds from scratch on arm change), so any tab's /// `c_game_container_fullscreen` overlay is unmounted along with the -/// rest of that arm. The per-tab fullscreen signals are page-scoped -/// `Signal` instances, however — they survive arm destruction -/// because they are registered with the page-level HookContext, not -/// the per-arm one. Without explicit cleanup the next time the user -/// revisits that tab the overlay re-mounts even though they did not -/// press Enter Fullscreen again. Clearing all three signals on every -/// tab change keeps fullscreen state strictly co-extensive with the -/// user's last explicit enter/exit action. +/// rest of that arm. The per-tab fullscreen signals, however, are +/// page-scoped `Signal` instances that survive arm destruction +/// because they are registered with the page-level HookContext, not the +/// per-arm one. Without explicit cleanup the next time the user revisits +/// that tab the overlay re-mounts even though they did not press Enter +/// Fullscreen again. Clearing all three signals on every tab change keeps +/// fullscreen state strictly co-extensive with the user's last explicit +/// enter/exit action. /// /// # Arguments /// @@ -2152,13 +2240,11 @@ pub(crate) fn raytrace_on_tab_select( /// Enters landscape fullscreen mode for the RayTrace page on the active /// tab. /// -/// Sets the tab-specific fullscreen signal, pushes a history entry so -/// the system back button can exit, and re-applies safe-area insets to -/// the newly-mounted overlay container. A synthetic window `resize` -/// event is dispatched so the WebGL / WebGPU loops resize their backing -/// stores to the new CSS box; the Canvas 2D tab keeps its fixed 4:3 -/// backing and relies on `object-fit: contain` letterboxing, so it -/// ignores the event. +/// Sets the tab-specific fullscreen signal, pushes a history entry so the +/// system back button can exit, and re-applies safe-area insets to the +/// newly-mounted overlay container. A synthetic window `resize` event is +/// dispatched so the GPU-backed loops resize their backing stores to the +/// new CSS box. /// /// # Arguments /// @@ -2169,8 +2255,7 @@ pub(crate) fn enter_raytrace_fullscreen(tab: Signal) { UseEuvLayout::apply_cached_insets(); // Dispatch a `resize` event on the window so the GPU-backed loops' // `App::use_window_event(EVENT_RESIZE, ...)` handlers fire and their - // `resize_dirty` flags are set. Mirrors - // `game_3d/hook/fn.rs::enter_game_3d_fullscreen`. + // `resize_dirty` flags are set. let Some(window_value): Option = window() else { return; }; @@ -2211,8 +2296,8 @@ pub(crate) fn exit_raytrace_fullscreen(tab: Signal) { /// /// Used when the exit is triggered by the system back button: the /// `popstate` event itself has already consumed the `pushState` entry -/// that was created when entering fullscreen, so calling -/// `history.back()` again would over-consume the history stack. +/// that was created when entering fullscreen, so calling `history.back()` +/// again would over-consume the history stack. /// /// # Arguments /// diff --git a/example/src/page/raytrace/hook/mod.rs b/example/src/page/raytrace/hook/mod.rs index 087acc65..1a620c69 100644 --- a/example/src/page/raytrace/hook/mod.rs +++ b/example/src/page/raytrace/hook/mod.rs @@ -2,7 +2,8 @@ mod r#const; mod r#fn; mod r#impl; mod r#struct; +mod r#type; -pub(crate) use {r#const::*, r#fn::*, r#struct::*}; +pub(crate) use {r#const::*, r#fn::*, r#struct::*, r#type::*}; use super::*; diff --git a/example/src/page/raytrace/hook/struct.rs b/example/src/page/raytrace/hook/struct.rs index 1ea4fb53..0efb78ac 100644 --- a/example/src/page/raytrace/hook/struct.rs +++ b/example/src/page/raytrace/hook/struct.rs @@ -4,110 +4,179 @@ use super::*; #[derive(Clone, Copy, Data, Debug, Default, PartialEq)] pub(crate) struct UseRayTrace { /// The current frames-per-second measurement. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) fps: Signal, /// Whether the raytrace loop is currently running. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) running: Signal, /// Whether the raytrace loop has been kicked off in this component tree. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) loop_started: Signal, /// Whether the camera auto-rotates around the scene each frame. /// /// Dragging on the canvas disables auto-rotate for the rest of the /// session; the toolbar button re-enables it. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) auto_rotate: Signal, /// The current adaptive internal render scale (1.0 = full 320x240, /// 2.0 = 640x480 super-sampled). - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) render_scale: Signal, - /// Whether the RayTrace Canvas 2D tab has finished its first warmup - /// frame and is ready to render. + /// Whether the RayTrace Canvas 2D tab has finished its first render and + /// is ready to present. /// - /// Mirrors the `loaded` signal exposed by the WebGL / WebGPU tabs - /// so all three RayTrace tabs share the same loading-overlay UX: - /// the view renders a `c_game_loading_overlay` canvas on top of the - /// raytrace canvas while `loaded` is `false`, and the loop flips it - /// to `true` after the SSAA wrapper is acquired and the first frame - /// has been traced. Without this signal the Canvas 2D tab would - /// show a blank / half-rendered canvas during the 200-400 ms - /// warmup that the SSAA acquire + first per-pixel ray pass cost - /// (scenes are precomputed but the offscreen canvas allocate + - /// 2D-context acquire + initial put_image_data are not free). - #[get(type(copy))] + /// Mirrors the `loaded` signal exposed by the WebGL / WebGPU tabs so all + /// three RayTrace tabs share the same loading-overlay UX: the view + /// renders a `c_game_loading_overlay` canvas on top of the raytrace + /// canvas while `loaded` is `false`, and the loop flips it to `true` + /// after a frame has actually been rendered. Without this signal the + /// Canvas 2D tab would show a blank canvas during backend acquisition + /// and the first per-pixel ray pass. + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) loaded: Signal, - /// Whether the Canvas 2D renderer is active and rendering. The - /// WebGL / WebGPU tabs already carry an `active` signal; mirroring - /// it here keeps the status banner code path identical across - /// tabs and gives the future engine-rewrite a uniform status - /// surface to read from. - #[get(type(copy))] + /// Whether the Canvas 2D backend rendered a frame successfully and + /// most recently still is. + /// + /// The view reads this for its status line, and the loop clears it on + /// every rejected `render` call. That direction matters: a flag that + /// only ever flips to `true` would keep reporting a healthy renderer + /// after the backend lost its device or started rejecting frames. + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) active: Signal, + /// The most recent engine rejection detail as a human-readable string. + /// + /// Empty when the backend is healthy. Shown verbatim in the tab's + /// status line so a rejection is never reduced to a generic "failed". + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] + pub(crate) error_detail: Signal, + /// The most recent rejected call site as a stable code. + /// + /// Empty when the backend is healthy. Distinguishes "the browser cannot + /// host this backend" from "the backend died mid-run", which the raw + /// detail string alone does not convey. Stored as a code rather than an + /// error enum so the state stays `Copy`. + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] + pub(crate) init_error_code: Signal<&'static str>, } /// Reactive state for the RayTrace WebGL backend tab. #[derive(Clone, Copy, Data, Debug, Default, PartialEq)] pub(crate) struct UseRayTraceWebGl { /// The current frames-per-second measurement. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) fps: Signal, /// Whether the WebGL raytrace loop is currently running. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) running: Signal, /// Whether the camera auto-rotates around the scene each frame. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) auto_rotate: Signal, - /// Whether the WebGL renderer has finished initializing (success or failure). - #[get(type(copy))] + /// Whether the WebGL backend has finished initializing (success or + /// failure). + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) loaded: Signal, - /// Whether the WebGL renderer is active and rendering. - #[get(type(copy))] + /// Whether the WebGL backend rendered a frame successfully and most + /// recently still is. + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) active: Signal, - /// Whether the WebGL render loop has been kicked off in this component tree. - #[get(type(copy))] + /// Whether the WebGL render loop has been kicked off in this component + /// tree. + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) loop_started: Signal, /// The most recent init error code as a stable string. /// /// Drives the diagnostic banner shown when `loaded` is true but - /// `active` is false. The empty string means "no error" (i.e. init is - /// still in flight or has not started). Storing a stable code rather - /// than the full `WebGl2InitError` keeps this state `Copy` and avoids - /// surfacing JS error detail into the reactive UI tree. - #[get(type(copy))] + /// `active` is false. The empty string means "no error". + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) init_error_code: Signal<&'static str>, + /// The most recent engine rejection detail as a human-readable string. + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] + pub(crate) error_detail: Signal, } /// Reactive state for the RayTrace WebGPU backend tab. #[derive(Clone, Copy, Data, Debug, Default, PartialEq)] pub(crate) struct UseRayTraceWebGpu { /// The current frames-per-second measurement. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) fps: Signal, /// Whether the WebGPU raytrace loop is currently running. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) running: Signal, /// Whether the camera auto-rotates around the scene each frame. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) auto_rotate: Signal, - /// Whether the WebGPU renderer has finished initializing (success or failure). - #[get(type(copy))] + /// Whether the WebGPU backend has finished initializing (success or + /// failure). + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) loaded: Signal, - /// Whether the WebGPU renderer is active and rendering. - #[get(type(copy))] + /// Whether the WebGPU backend rendered a frame successfully and most + /// recently still is. + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) active: Signal, - /// Whether the WebGPU render loop has been kicked off in this component tree. - #[get(type(copy))] + /// Whether the WebGPU render loop has been kicked off in this component + /// tree. + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) loop_started: Signal, /// The most recent init error code as a stable string. /// /// Drives the diagnostic banner shown when `loaded` is true but - /// `active` is false. The empty string means "no error" (i.e. init is - /// still in flight or has not started). Storing a stable code rather - /// than the full `WebGpuInitError` keeps this state `Copy` and avoids - /// surfacing JS error detail into the reactive UI tree. - #[get(type(copy))] + /// `active` is false. The empty string means "no error". + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) init_error_code: Signal<&'static str>, + /// The most recent engine rejection detail as a human-readable string. + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] + pub(crate) error_detail: Signal, } /// Reactive state for the RayTrace page fullscreen overlay. @@ -115,19 +184,25 @@ pub(crate) struct UseRayTraceWebGpu { /// Each rendering tab (Canvas 2D / WebGL / WebGPU) keeps an independent /// `fullscreen` signal because the canvas DOM, the render loop, and the /// GPU device are all tab-specific. The three signals are stacked into a -/// single `UseRayTraceFullscreen` so the page-level `popstate` guard can -/// be registered once and dispatch against whichever tab is currently in +/// single `UseRayTraceFullscreen` so the page-level `popstate` guard can be +/// registered once and dispatch against whichever tab is currently in /// fullscreen. #[derive(Clone, Copy, Data, Debug, Default, PartialEq)] pub(crate) struct UseRayTraceFullscreen { /// Whether the Canvas 2D tab is currently in landscape fullscreen. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) canvas_2d: Signal, /// Whether the WebGL tab is currently in landscape fullscreen. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) web_gl: Signal, /// Whether the WebGPU tab is currently in landscape fullscreen. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) web_gpu: Signal, } diff --git a/example/src/page/raytrace/hook/type.rs b/example/src/page/raytrace/hook/type.rs new file mode 100644 index 00000000..f19c46df --- /dev/null +++ b/example/src/page/raytrace/hook/type.rs @@ -0,0 +1,45 @@ +use super::*; + +/// One scheduled `setTimeout` waiting to be cancelled or fired. +/// +/// The handle and the Rust closure the browser will invoke travel together, +/// so teardown can cancel the timer and then drop the closure instead of +/// leaking it with `Closure::forget`. +pub(crate) type RayTracePendingTimer = (i32, Closure); + +/// Borrowed view of the pending-timer list handed to a cleanup path. +pub(crate) type RayTracePendingTimerGuard<'a> = RefMut<'a, Vec>; + +/// The Rust callback a live `ResizeObserver` invokes. +pub(crate) type RayTraceObserverCallback = Closure; + +/// The Rust callback a debounced window-resize listener invokes. +pub(crate) type RayTraceDebounceCallback = Closure; + +/// Borrowed observer-callback slot taken during teardown. +pub(crate) type RayTraceObserverCallbackSlot<'a> = RefMut<'a, Option>; + +/// Borrowed debounce-callback slot taken during teardown. +pub(crate) type RayTraceDebounceCallbackSlot<'a> = RefMut<'a, Option>; + +/// Owner list for every `setTimeout` a RayTrace loop schedules. +/// +/// Each entry pairs the timeout handle with the Rust closure the browser +/// invokes when it fires, so teardown can cancel the timer and then drop +/// the closure instead of leaking it with `Closure::forget`. +pub(crate) type RayTracePendingTimers = Rc>>; + +/// Sole owner of the debounced window-resize listener's Rust callback. +/// +/// The listener outlives the function that registers it, so the closure +/// cannot live in a local. Cleanup releases it only after the tab is +/// switched away, because the listener stays able to fire until then. +pub(crate) type RayTraceDebounceOwner = Rc>>>; + +/// Sole owner of a `ResizeObserver`'s Rust callback. +/// +/// The observer is built asynchronously but keeps firing long after init +/// returns, so the closure is parked here and dropped only once cleanup +/// has disconnected the observer. +pub(crate) type RayTraceObserverClosureOwner = + Rc>>>; diff --git a/example/src/page/raytrace/view/const.rs b/example/src/page/raytrace/view/const.rs index 0dd11b1d..a5e2fc08 100644 --- a/example/src/page/raytrace/view/const.rs +++ b/example/src/page/raytrace/view/const.rs @@ -2,7 +2,7 @@ pub(crate) const RAYTRACE_HEADER_TITLE: &str = "RayTrace"; /// The subtitle text for header on the ray-tracing page. -pub(crate) const RAYTRACE_HEADER_SUBTITLE: &str = "A real-time ray tracer rendered three ways: a Canvas 2D software path with an ImageData fast path and adaptive internal resolution, a WebGL 2 GLSL fragment-shader path, and a WebGPU WGSL path. All backends trace the same scene (1 mirror sphere + 1 emissive sphere + 1 ground AABB) with 2x2 SSAA and up to 4 reflection bounces, and every tab reports an honest wall-clock FPS. Drag the canvas to orbit the camera; the directional sun rotates with the yaw so the lit side of the spheres tracks the orbiting camera. Click Enter Fullscreen for a larger view."; +pub(crate) const RAYTRACE_HEADER_SUBTITLE: &str = "One ray tracer, three rendering backends. Every tab renders the identical scene through euv-engine's shared RayTraceRenderer: a Canvas 2D software path with adaptive internal resolution, a WebGL 2 GLSL fragment-shader path, and a WebGPU WGSL path. The scene mixes all three primitive kinds the engine traces — a ground AABB, a mirror sphere, an emissive sphere, and a reflective triangle — lit by one directional sun, one point fill, and one spot rim, with 2x2 supersampling and up to 4 reflection bounces. This is shader BVH traversal, not native hardware ray tracing. Drag the canvas to orbit the camera; every tab reports an honest wall-clock FPS. Click Enter Fullscreen for a larger view."; /// The `tab title canvas 2d` text used on the ray-tracing page. pub(crate) const RAYTRACE_TAB_TITLE_CANVAS_2D: &str = "RayTrace Demo (2D)"; @@ -17,13 +17,13 @@ pub(crate) const RAYTRACE_TAB_TITLE_WEBGPU: &str = "RayTrace Demo (GPU)"; pub(crate) const RAYTRACE_BACKENDS_CARD_TITLE: &str = "RayTrace Backends"; /// The long-form description of canvas on the ray-tracing page. -pub(crate) const RAYTRACE_CANVAS_DESCRIPTION: &str = "The Canvas 2D tab runs euv-engine's raytracing module on the CPU: every frame, for every pixel of the internal buffer, the camera fires a primary Ray through the scene using RayTraceScene::trace, which iteratively reflects up to 4 bounces with zero heap allocation per ray. LightingUniforms::shade combines ambient, Lambertian diffuse, and Phong specular per hit. Finished frames are packed into a persistent RGBA buffer (gamma 1/2.2) and uploaded with a single put_image_data call, and an EMA of the CPU frame time steps the internal resolution through a 640x480 .. 80x60 ladder (always 4:3, so the fullscreen letterbox holds) to protect the frame rate, starting at 320x240 and climbing only when the budget allows. The FPS counter measures unclamped wall-clock time."; +pub(crate) const RAYTRACE_CANVAS_DESCRIPTION: &str = "The Canvas 2D tab runs euv-engine's raytracing module on the CPU. Every frame goes through RayTraceRenderer::render, which traces the scene's bounding-volume hierarchy per pixel with 2x2 supersampling, shades each hit through LightingUniforms::shade_with_visibility using exact BVH shadow rays, averages the samples, applies a 1/2.2 gamma curve, and uploads the result with a single put_image_data. The scene and the light set are built once and never rebuilt: nothing in them moves with the camera, so no frame re-packs or re-clones the scene. An EMA of the measured render time steps the internal resolution through a 640x480 .. 80x60 ladder (always 4:3, so the fullscreen letterbox holds) to protect the frame rate, starting at 320x240 and climbing only when the budget allows. The FPS counter measures unclamped wall-clock time."; /// The long-form description of webgl on the ray-tracing page. -pub(crate) const RAYTRACE_WEBGL_DESCRIPTION: &str = "The WebGL tab runs the identical scene inside a GLSL ES 3.00 fragment shader drawn on an attribute-less fullscreen triangle (gl_VertexID, no vertex buffers): the ground AABB, mirror sphere, and emissive sphere are hardcoded in the shader, and the orbit camera basis, sun direction, ambient, and canvas resolution are uploaded per frame as a vec4 uniform array. The fragment shader mirrors the engine's trace_bounces and LightingUniforms::shade term for term (2x2 SSAA, 4 bounces, gamma 1/2.2), and the NDC is aspect-corrected from the resolution uniform so the scene never stretches at any canvas size. Works in every modern browser with WebGL 2 support."; +pub(crate) const RAYTRACE_WEBGL_DESCRIPTION: &str = "The WebGL tab renders the same scene through the engine's own GLSL ES 3.00 program on an attribute-less fullscreen triangle driven by gl_VertexID. The scene arrives as a single RGBA32F data texture holding the packed BVH nodes and primitive records, and the frame arrives as a second one; the fragment shader reads both with texelFetch and traverses the hierarchy itself, so no geometry, light count, or node count is baked into the shader. The per-frame work is a camera and lighting update against persistent textures and program objects: the static scene is uploaded exactly once, at initialization, and never re-sent. Shadows are real shadow rays through the same hierarchy, not a bounding-sphere approximation. Aspect comes from the resolution the frame is packed with, so the scene never stretches. Works in every modern browser with WebGL 2 support."; /// The long-form description of webgpu on the ray-tracing page. -pub(crate) const RAYTRACE_WEBGPU_DESCRIPTION: &str = "The WebGPU tab runs the same shader logic expressed in WGSL: a fullscreen triangle generated from @builtin(vertex_index) and a fragment stage that ray-traces the scene per pixel with 2x2 SSAA and up to 4 bounces. Per-frame data arrives in a single 8-vec4 uniform buffer at @group(0) @binding(0) via WebGpuRenderer's create_render_pipeline / create_uniform_buffer / render_frame_with_bind_group helpers. Requires a WebGPU-capable browser (Chrome 113+, Edge 113+)."; +pub(crate) const RAYTRACE_WEBGPU_DESCRIPTION: &str = "The WebGPU tab runs the same traversal expressed in WGSL: a fullscreen triangle generated from @builtin(vertex_index) and a fragment stage that traces the packed hierarchy per pixel with 2x2 supersampling and up to 4 bounces. The engine owns the pipeline, the bind group, and the two storage buffers, and it awaits shader compilation and device validation during initialization rather than discovering a broken pipeline on the first frame. After that, a steady frame reuses persistent buffers and does not re-encode the scene. Requires a WebGPU-capable browser (Chrome 113+, Edge 113+)."; /// The label text for pause on the ray-tracing page. pub(crate) const RAYTRACE_PAUSE_LABEL: &str = "Pause"; @@ -41,7 +41,7 @@ pub(crate) const RAYTRACE_AUTO_ROTATE_OFF_LABEL: &str = "Auto: Off"; pub(crate) const RAYTRACE_FPS_PREFIX: &str = "FPS: "; /// The fixed summary line for scene on the ray-tracing page. -pub(crate) const RAYTRACE_SCENE_SUMMARY: &str = "Scene: 1 mirror + 1 emissive + 1 ground"; +pub(crate) const RAYTRACE_SCENE_SUMMARY: &str = "Scene: AABB + 2 spheres + triangle"; /// The text printed before the scale readout on the ray-tracing page. pub(crate) const RAYTRACE_SCALE_PREFIX: &str = "Scale: "; @@ -69,3 +69,6 @@ pub(crate) const RAYTRACE_WEBGL_INIT_FAILED_STATUS: &str = "WebGL init failed"; /// The text printed before the status readout on the ray-tracing page. pub(crate) const RAYTRACE_STATUS_PREFIX: &str = "Status: "; + +/// The separator placed between a status code and its detail text. +pub(crate) const RAYTRACE_STATUS_DETAIL_SEPARATOR: &str = " — "; diff --git a/example/src/page/raytrace/view/fn.rs b/example/src/page/raytrace/view/fn.rs index 39c389dc..1daa17ce 100644 --- a/example/src/page/raytrace/view/fn.rs +++ b/example/src/page/raytrace/view/fn.rs @@ -3,20 +3,28 @@ use super::*; /// A standalone interactive ray-tracing demo page with three rendering /// backends. /// -/// All three tabs render the same scene — one mirror sphere, one -/// emissive sphere, and a ground AABB lit by one directional sun that -/// rotates with the camera yaw — through different backends: +/// All three tabs render the *same* static scene through euv-engine's +/// shared `RayTraceRenderer`, so the tabs differ only in how the engine +/// reaches the pixels: /// /// - **2D**: a CPU-side software ray tracer writing a persistent RGBA /// framebuffer with a single `put_image_data` per frame, with 2x2 /// SSAA and an adaptive internal resolution ladder. -/// - **GL**: a WebGL 2 GLSL ES 3.00 fragment shader mirroring the same -/// `trace_bounces` + `LightingUniforms::shade` math. -/// - **GPU**: the same pipeline expressed in WGSL on WebGPU. +/// - **GL**: a WebGL 2 GLSL ES 3.00 fragment shader traversing the +/// engine's packed BVH out of a data texture. +/// - **GPU**: the same traversal expressed in WGSL on WebGPU. +/// +/// The scene mixes every primitive kind the engine traces (an AABB, two +/// spheres, a triangle) and is lit by one directional sun, one point +/// fill, and one spot rim. It is built once and never rebuilt, because +/// nothing in it moves with the camera: the GPU backends upload it at +/// `init` and never again. This is shader BVH traversal, not native +/// hardware ray tracing. /// /// The camera is an orbit camera whose yaw / pitch are shared across /// tabs and driven by mouse drag, touch drag, or auto-rotation. The FPS -/// counter reports honest wall-clock rates on every tab. +/// counter reports honest wall-clock rates on every tab, and each tab +/// surfaces the engine's own rejection text when a call fails. /// /// # Arguments /// @@ -137,7 +145,7 @@ pub(crate) fn page_raytrace(node: VirtualNode) -> VirtualNode /// Renders the Canvas 2D software raytracing tab content. /// /// Contains the full Canvas 2D demo: stats bar (FPS, scene, adaptive -/// render scale), canvas, and controls. +/// render scale, backend status), canvas, and controls. /// /// # Arguments /// @@ -176,6 +184,15 @@ fn raytrace_canvas_tab( let on_touch_end: Option> = raytrace_on_touch_end(last_pointer.clone()); let fps_display: String = format!("{:.1}", state.get_fps().get()); let scale_display: String = format!("{:.0}%", state.get_render_scale().get() * 100.0); + let canvas_2d_loaded: bool = state.get_loaded().get(); + let canvas_2d_active: bool = state.get_active().get(); + let canvas_2d_error_code: &str = state.get_init_error_code().get(); + let canvas_2d_status: String = raytrace_status_text( + canvas_2d_loaded, + canvas_2d_active, + canvas_2d_error_code, + &state.get_error_detail().get(), + ); let pause_label: &str = if state.get_running().get() { RAYTRACE_PAUSE_LABEL } else { @@ -210,6 +227,14 @@ fn raytrace_canvas_tab( scale_display } } + span { + class: c_game_stats_label() + RAYTRACE_STATUS_PREFIX + span { + class: c_game_stats_total_value() + canvas_2d_status + } + } } div { class: if { canvas_2d_fullscreen.get() } { @@ -283,41 +308,65 @@ fn raytrace_canvas_tab( } } -/// Maps the WebGL init state plus the engine's stable error code to the +/// Maps a backend's init state plus the engine's stable error code to the /// banner text shown next to "Status: ". /// /// WebGL 2 is supported by every modern browser, so unlike the WebGPU /// banner this does not need a full capability decision tree: an init /// failure is almost always "browser too old" or a driver blocklist hit. /// +/// The `active` flag is checked *before* the error code on purpose. The +/// loop clears `active` on every rejected call, so a backend that failed +/// mid-run can never fall through to the "not supported" wording and claim +/// the browser lacks a feature it in fact ran. +/// /// # Arguments /// /// - `bool` - Whether initialization has finished (success or failure). /// - `bool` - Whether the renderer is active. -/// - `&str` - The `WebGl2InitError::code()` from the last init attempt. +/// - `&str` - The stable error code from the last rejected call. +/// - `&str` - The engine's rejection detail. /// /// # Returns /// -/// - `&'static str` - The banner text. -fn raytrace_webgl_status_text(loaded: bool, active: bool, init_error_code: &str) -> &'static str { - if !loaded { - return RAYTRACE_INITIALIZING_STATUS; - } - if active { - return RAYTRACE_WEBGL_ACTIVE_STATUS; - } - if init_error_code.is_empty() { +/// - `String` - The banner text, including the engine's own message when +/// there is one. +fn raytrace_status_text(loaded: bool, active: bool, init_error_code: &str, detail: &str) -> String { + let status: &str = if !loaded { + return RAYTRACE_INITIALIZING_STATUS.to_string(); + } else if active { + RAYTRACE_WEBGL_ACTIVE_STATUS + } else if init_error_code.is_empty() { RAYTRACE_WEBGL_NOT_SUPPORTED_STATUS } else { RAYTRACE_WEBGL_INIT_FAILED_STATUS + }; + raytrace_status_with_detail(status, detail) +} + +/// Appends the engine's rejection detail to a status line. +/// +/// # Arguments +/// +/// - `&str` - The base status text. +/// - `&str` - The engine's rejection detail; empty renders nothing extra. +/// +/// # Returns +/// +/// - `String` - The status line. +pub(crate) fn raytrace_status_with_detail(status: &str, detail: &str) -> String { + if detail.is_empty() { + return status.to_string(); } + format!("{status}{RAYTRACE_STATUS_DETAIL_SEPARATOR}{detail}") } /// Renders the WebGL raytracing tab content. /// /// Mirrors the Canvas 2D tab: the same scene, orbit camera, and -/// pointer/touch drag, rendered through a GLSL ES 3.00 program instead -/// of the 2D context. Adds a WebGL status readout to the stats bar. +/// pointer/touch drag, rendered through the engine's GLSL ES 3.00 program +/// instead of the 2D context. Adds a WebGL status readout to the stats +/// bar. /// /// # Arguments /// @@ -359,7 +408,12 @@ fn raytrace_webgl_tab( let loaded: bool = state.get_loaded().get(); let active: bool = state.get_active().get(); let init_error_code: &str = state.get_init_error_code().get(); - let status_text: &str = raytrace_webgl_status_text(loaded, active, init_error_code); + let status_text: String = raytrace_status_text( + loaded, + active, + init_error_code, + &state.get_error_detail().get(), + ); let pause_label: &str = if state.get_running().get() { RAYTRACE_PAUSE_LABEL } else { @@ -470,8 +524,9 @@ fn raytrace_webgl_tab( /// Renders the WebGPU raytracing tab content. /// /// Mirrors the Canvas 2D tab: the same scene, orbit camera, and -/// pointer/touch drag, rendered through a WGSL pipeline instead of the -/// 2D context. Adds a WebGPU status readout to the stats bar. +/// pointer/touch drag, rendered through the engine's WGSL pipeline +/// instead of the 2D context. Adds a WebGPU status readout to the stats +/// bar. /// /// # Arguments /// @@ -513,7 +568,14 @@ fn raytrace_webgpu_tab( let loaded: bool = state.get_loaded().get(); let active: bool = state.get_active().get(); let init_error_code: &str = state.get_init_error_code().get(); - let status_text: &str = webgpu_status_text(loaded, active, init_error_code); + // `webgpu_status_text` resolves the *capability* question (no WebGPU at + // all, adapter unavailable, device refused). A backend that acquired a + // device and then failed is not a capability problem, so the engine's + // own detail is appended to whatever wording that helper picked. + let status_text: String = raytrace_status_with_detail( + webgpu_status_text(loaded, active, init_error_code), + &state.get_error_detail().get(), + ); let pause_label: &str = if state.get_running().get() { RAYTRACE_PAUSE_LABEL } else { diff --git a/example/src/page/select/hook/struct.rs b/example/src/page/select/hook/struct.rs index b6a0b95a..31ff5132 100644 --- a/example/src/page/select/hook/struct.rs +++ b/example/src/page/select/hook/struct.rs @@ -4,24 +4,38 @@ use super::*; #[derive(Clone, Copy, Data, Debug, Default, Eq, Hash, New, Ord, PartialEq, PartialOrd)] pub(crate) struct UseSelect { /// The selected fruit value. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) selected_fruit: Signal, /// The selected country value. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) selected_country: Signal, /// The selected city value. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) selected_city: Signal, /// The available city options for the selected country. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) cities: Signal>, /// The feedback result message. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) feedback: Signal, /// The textarea content. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) textarea_content: Signal, /// The textarea validation error message. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) textarea_error: Signal, } diff --git a/example/src/page/sse/hook/struct.rs b/example/src/page/sse/hook/struct.rs index 589c7341..d8f25871 100644 --- a/example/src/page/sse/hook/struct.rs +++ b/example/src/page/sse/hook/struct.rs @@ -4,18 +4,28 @@ use super::*; #[derive(Clone, Copy, Data, Debug, Default, Eq, Hash, New, Ord, PartialEq, PartialOrd)] pub(crate) struct UseSse { /// The SSE endpoint URL input. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) url: Signal, /// Whether the SSE connection is currently active. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) connected: Signal, /// Whether the SSE connection is currently being established. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) connecting: Signal, /// The collected SSE event messages. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) messages: Signal>, /// The error message, empty if no error. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) error: Signal, } diff --git a/example/src/page/timer/hook/struct.rs b/example/src/page/timer/hook/struct.rs index 73cac44e..22dccfea 100644 --- a/example/src/page/timer/hook/struct.rs +++ b/example/src/page/timer/hook/struct.rs @@ -7,13 +7,19 @@ use super::*; #[derive(Clone, Copy, Data, Debug, Default, Eq, Hash, New, Ord, PartialEq, PartialOrd)] pub(crate) struct UseStopwatch { /// The elapsed seconds counter. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) seconds: Signal, /// Whether the stopwatch is currently running. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) running: Signal, /// The active interval handle, if any. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) handle: Signal>, } @@ -24,18 +30,28 @@ pub(crate) struct UseStopwatch { #[derive(Clone, Copy, Data, Debug, Default, Eq, Hash, New, Ord, PartialEq, PartialOrd)] pub(crate) struct UseCountdown { /// The total countdown seconds. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) total: Signal, /// The remaining countdown seconds. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) remaining: Signal, /// Whether the countdown is currently running. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) running: Signal, /// The active interval handle, if any. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) handle: Signal>, /// The user input string for setting countdown seconds. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) input: Signal, } diff --git a/example/src/page/websocket/hook/struct.rs b/example/src/page/websocket/hook/struct.rs index d3266e4e..ec44879c 100644 --- a/example/src/page/websocket/hook/struct.rs +++ b/example/src/page/websocket/hook/struct.rs @@ -35,24 +35,38 @@ pub(crate) struct WsServerMessage { #[derive(Clone, Copy, Data, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)] pub(crate) struct UseWebSocket { /// The WebSocket endpoint URL input. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) url: Signal, /// Whether the WebSocket connection is currently active. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) connected: Signal, /// Whether the WebSocket connection is currently being established. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) connecting: Signal, /// The WebSocket message input. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) message_input: Signal, /// The received WebSocket messages. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) messages: Signal>, /// The error message, empty if no error. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) error: Signal, /// The interval handle for the Ping keep-alive timer. - #[get(type(copy))] + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) ping_handle: Signal>, } diff --git a/macros/src/class/struct.rs b/macros/src/class/struct.rs index e7c5c16c..aa94a9f1 100644 --- a/macros/src/class/struct.rs +++ b/macros/src/class/struct.rs @@ -172,13 +172,26 @@ pub(crate) struct ClassInput { } /// Intermediate result from parsing a block's content. +/// +/// Narrowed for the same reason as [`ClassDef`]: every reader is in this +/// crate (`class/fn.rs`, `class/impl.rs`), and the bundle is consumed by +/// destructuring — no accessor of it is called from generated code. #[derive(Clone, Data, Debug, New)] pub(crate) struct BlockContent { /// The CSS properties in this block. + #[get(pub(crate))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) properties: Vec<(ClassPropKey, ClassPropValue)>, /// The nested selector blocks. + #[get(pub(crate))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) selector_blocks: Vec, /// The nested at-rule blocks. + #[get(pub(crate))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) at_rule_blocks: Vec, } @@ -207,28 +220,66 @@ pub(crate) struct OnceLockParams<'a> { /// /// Bundled into one struct so [`emit_param_css_cache_fn`](super::emit_param_css_cache_fn) /// takes a single argument instead of twelve positional ones. +/// +/// Narrowed to `pub(crate)` for the same reason as [`OnceLockParams`]: the +/// consumer is [`emit_param_css_cache_fn`](super::emit_param_css_cache_fn), +/// which destructures the bundle, and the quoted tokens use the destructured +/// bindings — so no accessor is reachable from generated code. #[derive(Data)] pub(crate) struct ParamCssCacheArgs<'a> { /// The visibility the generated cache function is emitted with. + #[get(pub(crate))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) visibility: &'a Visibility, /// The generated function's name token. + #[get(pub(crate))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) fn_name_token: &'a proc_macro2::TokenStream, /// The span to attribute the cache constant's name to. + #[get(pub(crate))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) fn_name_span: proc_macro2::Span, /// The kebab-case class name the cache is keyed by. + #[get(pub(crate))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) class_name_str: &'a str, /// The parameter name identifiers forming the cache key. + #[get(pub(crate))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) param_names: &'a [proc_macro2::TokenStream], /// The `unique_name` expression driving cache key uniqueness. + #[get(pub(crate))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) unique_name_expr: &'a proc_macro2::TokenStream, /// The expression producing the class's style declarations. + #[get(pub(crate))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) style_expr: &'a proc_macro2::TokenStream, /// The expression producing the class's selector. + #[get(pub(crate))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) selector_expr: &'a proc_macro2::TokenStream, /// The expression producing the at-rule wrapping the declarations. + #[get(pub(crate))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) at_rule_expr: &'a proc_macro2::TokenStream, /// The generated parameter definitions for the class function. + #[get(pub(crate))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) param_defs: &'a [proc_macro2::TokenStream], /// The class function's generics and where clause. + #[get(pub(crate))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) generics: &'a syn::Generics, } diff --git a/ui/src/component/skeleton/view/struct.rs b/ui/src/component/skeleton/view/struct.rs index 8c807d08..0d1f0c02 100644 --- a/ui/src/component/skeleton/view/struct.rs +++ b/ui/src/component/skeleton/view/struct.rs @@ -10,6 +10,7 @@ use super::*; pub struct EuvSkeletonProps { /// The number of placeholder lines to draw, clamped to /// [`skeleton_line_count`]'s `1..=8` range. + #[get(type(copy))] pub lines: usize, /// The inline width applied to every line, e.g. `"60%"` (falls back /// to `"100%"` when empty or not a percentage). diff --git a/ui/src/component/touch/hook/impl.rs b/ui/src/component/touch/hook/impl.rs index dca8052b..87d61123 100644 --- a/ui/src/component/touch/hook/impl.rs +++ b/ui/src/component/touch/hook/impl.rs @@ -315,7 +315,7 @@ impl EuvGestureRecognizer { &point, slot.get_start_x(), slot.get_start_y(), - *slot.get_travel(), + slot.get_travel(), ) }); let pinch_reading: Option = move_recognizer @@ -700,7 +700,7 @@ impl GestureProgress { let dy: f64 = self.get_last_y() - self.get_start_y(); let elapsed: f64 = now_millis() - self.get_started_at(); let distance: f64 = (dx * dx + dy * dy).sqrt(); - if distance <= config.tap_slop && *self.get_travel() <= config.tap_slop { + if distance <= config.tap_slop && self.get_travel() <= config.tap_slop { return Some(if elapsed >= config.long_press_millis { EuvGesture::LongPress } else { diff --git a/ui/src/component/touch/hook/struct.rs b/ui/src/component/touch/hook/struct.rs index e26cd9c9..7697764d 100644 --- a/ui/src/component/touch/hook/struct.rs +++ b/ui/src/component/touch/hook/struct.rs @@ -128,7 +128,6 @@ pub struct EuvDrag { /// A finger that traces a loop has a large path length and zero delta, /// which is how a drag is told apart from a tap that wandered slightly. #[get(type(copy))] - #[get(type(copy))] pub travel: f64, } @@ -224,6 +223,7 @@ pub struct GestureProgress { #[get(type(copy))] pub last_y: f64, /// Accumulated path length in CSS pixels. + #[get(type(copy))] pub travel: f64, /// Distance between the two fingers when a pinch began. #[get(type(copy))] diff --git a/ui/src/hook/counter/impl.rs b/ui/src/hook/counter/impl.rs index 231d2b49..4c5de025 100644 --- a/ui/src/hook/counter/impl.rs +++ b/ui/src/hook/counter/impl.rs @@ -7,7 +7,7 @@ impl Counter { /// value is left at `max`. pub fn increment(&self) { let current: i32 = self.get_value().get(); - let mut next: i32 = current.saturating_add(*self.get_step()); + let mut next: i32 = current.saturating_add(self.get_step()); if let Some(max) = self.try_get_max() { next = next.min(max); } @@ -22,7 +22,7 @@ impl Counter { /// at `min`, the value is left at `min`. pub fn decrement(&self) { let current: i32 = self.get_value().get(); - let mut next: i32 = current.saturating_sub(*self.get_step()); + let mut next: i32 = current.saturating_sub(self.get_step()); if let Some(min) = self.try_get_min() { next = next.max(min); } diff --git a/ui/src/hook/counter/struct.rs b/ui/src/hook/counter/struct.rs index 443f54c4..015083d0 100644 --- a/ui/src/hook/counter/struct.rs +++ b/ui/src/hook/counter/struct.rs @@ -15,14 +15,26 @@ use super::*; pub struct Counter { /// The current value signal. Defaults to /// `Signal::create(0)` because of `#[new(skip)]`. + /// + /// Public because `ui/tests/counter/fn.rs` reads it + /// through `get_value()`. #[new(skip)] - pub(crate) value: Signal, + pub value: Signal, /// Optional minimum bound. `None` means unbounded below. + /// + /// Public because `ui/tests/counter/fn.rs` reads it + /// through `get_min()`. #[get(type(copy))] - pub(crate) min: Option, + pub min: Option, /// Optional maximum bound. `None` means unbounded above. + /// + /// Public because `ui/tests/counter/fn.rs` reads it + /// through `get_max()`. #[get(type(copy))] - pub(crate) max: Option, + pub max: Option, /// Step size used by `increment` / `decrement`. + #[get(type(copy), pub(crate))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) step: i32, } diff --git a/ui/src/hook/debounced_value/impl.rs b/ui/src/hook/debounced_value/impl.rs index 689ee1af..a7588e85 100644 --- a/ui/src/hook/debounced_value/impl.rs +++ b/ui/src/hook/debounced_value/impl.rs @@ -42,7 +42,7 @@ impl DebouncedValue { match self.get_state().get() { DebounceState::Idle => false, DebounceState::Pending(set_at, _) => { - if now_ms.saturating_sub(set_at) >= u64::from(*self.get_delay_ms()) { + if now_ms.saturating_sub(set_at) >= u64::from(self.get_delay_ms()) { let pending: T = match self.get_state().get() { DebounceState::Pending(_, value) => value, DebounceState::Idle => unreachable!(), diff --git a/ui/src/hook/debounced_value/struct.rs b/ui/src/hook/debounced_value/struct.rs index 273ba8bb..795acb67 100644 --- a/ui/src/hook/debounced_value/struct.rs +++ b/ui/src/hook/debounced_value/struct.rs @@ -27,15 +27,30 @@ pub struct DebouncedValue { /// The emitted value signal. Defaults to /// `Signal::create(T::default())` via /// `#[new(skip)]`. + /// + /// Public because `example/src/page/hooks_timing/view/fn.rs` + /// reads it through `get_value()`. #[new(skip)] #[get(type(copy))] - pub(crate) value: Signal, + pub value: Signal, /// The internal pending/empty state. Defaults to /// `Signal::create(DebounceState::Idle)` via /// `#[new(skip)]`. + /// + /// `get_state()` is only called by `impl.rs`, so the three + /// generated accessors are all `pub(crate)`. + #[get(pub(crate))] + #[get_mut(pub(crate))] + #[set(pub(crate))] #[new(skip)] pub(crate) state: Signal>, /// The quiet period in milliseconds. + /// + /// `get_delay_ms()` is only called by `impl.rs`, so the + /// three generated accessors are all `pub(crate)`. + #[get(type(copy), pub(crate))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) delay_ms: u32, } diff --git a/ui/src/hook/error_boundary/struct.rs b/ui/src/hook/error_boundary/struct.rs index 8978c0d8..1e1d649f 100644 --- a/ui/src/hook/error_boundary/struct.rs +++ b/ui/src/hook/error_boundary/struct.rs @@ -16,5 +16,9 @@ use super::*; #[derive(Clone, Data, Debug)] pub struct ErrorBoundary { /// The phase signal. - pub(crate) phase: Signal, + /// + /// Public because `ui/tests/error_boundary/fn.rs` and + /// `example/src/page/hooks_protect` read it through + /// `get_phase()`. + pub phase: Signal, } diff --git a/ui/src/hook/form/struct.rs b/ui/src/hook/form/struct.rs index 94fe92ea..d751b184 100644 --- a/ui/src/hook/form/struct.rs +++ b/ui/src/hook/form/struct.rs @@ -18,14 +18,28 @@ use super::*; #[derive(Clone, Data, New)] pub struct FormState { /// Per-field current value, keyed by field name. - pub(crate) values: Signal>, + /// + /// Public because `ui/tests/form/fn.rs` and + /// `example/src/page/form` read it through + /// `get_values()`. + pub values: Signal>, /// Per-field validation error, keyed by field name. /// Empty string means "no error". - pub(crate) errors: Signal>, + /// + /// Public because `ui/tests/form/fn.rs` and + /// `example/src/page/form` read it through + /// `get_errors()`. + pub errors: Signal>, /// Per-field "user has interacted with this field" flag. - pub(crate) touched: Signal>, + /// + /// Public because `ui/tests/form/fn.rs` reads it + /// through `get_touched()`. + pub touched: Signal>, /// True while a `submit` handler is running. Render /// `