diff --git a/addons/handles/test/core-mock.ts b/addons/handles/test/core-mock.ts index 4deed2f..85d2830 100644 --- a/addons/handles/test/core-mock.ts +++ b/addons/handles/test/core-mock.ts @@ -129,6 +129,7 @@ function deserialize(serialized: string) { export class YawnCore { readonly ready = Promise.resolve(); + readonly features = new Set(["timestamp-query", "indirect-first-instance"]); readonly graphs: any[] = []; readonly serializedGraphs: string[] = []; readonly loadouts: string[] = []; @@ -171,11 +172,11 @@ export class YawnCore { return Promise.resolve(); } - allocateObject(name: string) { + allocateObject(name: string, count = 1) { const id = this.#nextObjects.get(name) ?? 0; - this.#nextObjects.set(name, id + 1); + this.#nextObjects.set(name, id + count); const rows = this.array(name); - rows.resize(id + 1); + rows.resize(id + count); return Promise.resolve(id); } diff --git a/core/index.js b/core/index.js index cc8ee45..f767ad4 100644 --- a/core/index.js +++ b/core/index.js @@ -48,6 +48,8 @@ export class YawnCore { #pending = new Map(); #profileListeners = new Set(); #next = 1; + /** Optional WebGPU features the device has (WebGPU spelling); populated once `ready` resolves. */ + features = new Set(); constructor(canvas, { arenaBytes = 64 * 1024 * 1024, debug = false, workerFactory } = {}) { if (!canvas) throw new TypeError("canvas is required"); @@ -62,6 +64,7 @@ export class YawnCore { const offscreen = canvas.transferControlToOffscreen?.() ?? canvas; this.ready = this.#request("init", { canvas: offscreen, arenaBytes }, [offscreen]).then(async result => { this.#buffer = result.buffer; + this.features = new Set(result.features); for (const descriptor of result.rows) this.#arrays.set( descriptor.name, new SharedRows(this.#buffer, descriptor), diff --git a/core/lib.rs b/core/lib.rs index 880fa30..ee0f340 100644 --- a/core/lib.rs +++ b/core/lib.rs @@ -87,6 +87,12 @@ impl Core { serde_json::to_string(&self.data.borrow().descriptors()).unwrap() } + /// Optional WebGPU features the device was created with (for example `indirect-first-instance`). + pub fn features(&self) -> String { + let gpu = self.gpu.borrow(); + serde_json::to_string(&gpu.as_ref().map(|gpu| &gpu.features[..]).unwrap_or(&[])).unwrap() + } + pub fn create_rows( &self, name: String, diff --git a/core/render_graph/gpu_resource.rs b/core/render_graph/gpu_resource.rs index c311ce7..2ab5d1e 100644 --- a/core/render_graph/gpu_resource.rs +++ b/core/render_graph/gpu_resource.rs @@ -212,22 +212,14 @@ impl GpuResources { for execution in &graph.executions { let compiled = match execution { Execution::Render(passes) => { - let (bundle, draws) = resources.render_bundle(graph, passes, gpu)?; + let (bundle, draws) = resources.render_bundle(graph, passes, gpu, data)?; + let first = &graph.passes[passes[0]].id; + let last = &graph.passes[*passes.last().unwrap()].id; GpuPass::Render { label: if passes.len() == 1 { - graph.passes[passes[0]].id.clone() - } else if passes - .iter() - .all(|index| graph.passes[*index].id.starts_with("forward-")) - { - format!("Forward ({draws} draws)") - } else if passes - .iter() - .all(|index| graph.passes[*index].id.starts_with("depth-")) - { - format!("Depth ({draws} draws)") + format!("{first} ({draws} draws)") } else { - format!("Render ({draws} draws)") + format!("{first} … {last} ({draws} draws)") }, first: passes[0], last: *passes.last().unwrap(), @@ -405,6 +397,7 @@ impl GpuResources { graph: &RenderGraph, passes: &[usize], gpu: &Wgpu, + data: &RenderData, ) -> Result<(wgpu::RenderBundle, usize), String> { let pass = &graph.passes[passes[0]]; let declaration = graph @@ -441,9 +434,8 @@ impl GpuResources { let mut previous_pipeline = None; let mut previous_bindings: Option<&[Binding]> = None; let mut draws = 0; - let mut at = 0; - while at < passes.len() { - let pass = &graph.passes[passes[at]]; + for &at in passes { + let pass = &graph.passes[at]; let pipeline = self .render_pipelines .get(&pass.pipeline) @@ -469,7 +461,7 @@ impl GpuResources { .buffer; encoder.set_vertex_buffer(binding.slot, buffer.slice(binding.offset..)); } - if let Some(binding) = &pass.index_buffer { + let indexed = if let Some(binding) = &pass.index_buffer { let buffer = &self .buffers .get(&binding.resource) @@ -479,26 +471,53 @@ impl GpuResources { buffer.slice(binding.offset..), parse(&binding.format, "GRAPH_INDEX_FORMAT")?, ); - let mut instances = pass.draw.instances; - while at + 1 < passes.len() - && can_instance(pass, &graph.passes[passes[at + 1]], instances) - { - at += 1; - instances += graph.passes[passes[at]].draw.instances; - } - encoder.draw_indexed( - pass.draw.first_index..pass.draw.first_index + pass.draw.indices, - pass.draw.base_vertex, - pass.draw.first_instance..pass.draw.first_instance + instances, - ); + true } else { - encoder.draw( - pass.draw.first_vertex..pass.draw.first_vertex + pass.draw.vertices, - pass.draw.first_instance..pass.draw.first_instance + pass.draw.instances, - ); + false + }; + match (&pass.indirect, indexed) { + (Some(indirect), indexed) => { + let buffer = self + .buffers + .get(&indirect.resource) + .ok_or("GRAPH_RESOURCE_UNKNOWN")?; + let rows = data.rows(&buffer.source).ok_or("GRAPH_ARRAY_UNKNOWN")?; + let end = match indirect.count { + Some(count) => indirect.first.checked_add(count), + None => Some(rows.rows), + } + .filter(|end| indirect.first <= *end && *end <= rows.rows) + .ok_or("GRAPH_INDIRECT")?; + if rows.stride < if indexed { 20 } else { 16 } { + return Err("GRAPH_INDIRECT".into()); + } + let count = end - indirect.first; + for row in indirect.first..end { + let offset = u64::from(row) * u64::from(rows.stride); + if indexed { + encoder.draw_indexed_indirect(&buffer.buffer, offset); + } else { + encoder.draw_indirect(&buffer.buffer, offset); + } + } + draws += count as usize; + } + (None, true) => { + encoder.draw_indexed( + pass.draw.first_index..pass.draw.first_index + pass.draw.indices, + pass.draw.base_vertex, + pass.draw.first_instance..pass.draw.first_instance + pass.draw.instances, + ); + draws += 1; + } + (None, false) => { + encoder.draw( + pass.draw.first_vertex..pass.draw.first_vertex + pass.draw.vertices, + pass.draw.first_instance..pass.draw.first_instance + pass.draw.instances, + ); + draws += 1; + } } - draws += 1; - at += 1; } Ok(( encoder.finish(&wgpu::RenderBundleDescriptor { @@ -554,18 +573,6 @@ impl GpuResources { } } -fn can_instance(first: &Pass, next: &Pass, instances: u32) -> bool { - first.pipeline == next.pipeline - && first.bindings == next.bindings - && first.vertex_buffers == next.vertex_buffers - && first.index_buffer == next.index_buffer - && first.draw.indices != 0 - && first.draw.indices == next.draw.indices - && first.draw.first_index == next.draw.first_index - && first.draw.base_vertex == next.draw.base_vertex - && first.draw.first_instance + instances == next.draw.first_instance -} - impl GpuPass { fn label(&self) -> &str { match self { diff --git a/core/render_graph/render_graph.rs b/core/render_graph/render_graph.rs index 8492b46..eeb2c2b 100644 --- a/core/render_graph/render_graph.rs +++ b/core/render_graph/render_graph.rs @@ -192,10 +192,23 @@ pub struct Pass { pub index_buffer: Option, #[serde(default)] pub draw: Draw, + pub indirect: Option, #[serde(default = "dispatch")] pub dispatch: [u32; 3], } +/// Draw commands read from rows of an `indirect` buffer: one command per row starting at +/// `first`, `count` rows (default: every row to the end of the array). The command occupies the +/// first 16 bytes (non-indexed) or 20 bytes (indexed) of each row; the remainder of the row is +/// free for the caller. +#[derive(Clone, Deserialize, PartialEq, Eq)] +pub struct IndirectBinding { + pub resource: String, + #[serde(default)] + pub first: u32, + pub count: Option, +} + #[derive(Clone, Deserialize, PartialEq, Eq)] pub struct Binding { #[serde(default)] @@ -344,6 +357,17 @@ impl RenderGraph { "compute" => _ = compute_pipelines.insert(pass.pipeline.clone()), _ => return Err("GRAPH_PASS"), } + if let Some(indirect) = &pass.indirect { + let usable = self + .resources + .buffers + .iter() + .find(|buffer| buffer.id == indirect.resource) + .is_some_and(|buffer| buffer.usage.iter().any(|usage| usage == "indirect")); + if !usable || pass.draw != Draw::default() { + return Err("GRAPH_INDIRECT"); + } + } for id in pass.resources() { used.insert(id.to_owned()); lifetimes @@ -532,6 +556,7 @@ impl Pass { .iter() .map(|value| value.resource.as_str()), ) + .chain(self.indirect.iter().map(|value| value.resource.as_str())) .collect() } } diff --git a/core/renderer/wgpu.rs b/core/renderer/wgpu.rs index e90594d..c2c9fcf 100644 --- a/core/renderer/wgpu.rs +++ b/core/renderer/wgpu.rs @@ -8,9 +8,19 @@ pub struct Wgpu { pub width: u32, pub height: u32, pub timestamp_queries: bool, + /// Optional features the device was created with, in WebGPU spelling. + pub features: Vec<&'static str>, pub adapter: String, } +const OPTIONAL_FEATURES: [(wgpu::Features, &str); 2] = [ + (wgpu::Features::TIMESTAMP_QUERY, "timestamp-query"), + ( + wgpu::Features::INDIRECT_FIRST_INSTANCE, + "indirect-first-instance", + ), +]; + impl Wgpu { pub async fn new(canvas: web_sys::OffscreenCanvas) -> Result { let width = canvas.width(); @@ -33,7 +43,12 @@ impl Wgpu { }) .await .map_err(|_| "WEBGPU_UNAVAILABLE")?; - let required_features = adapter.features() & wgpu::Features::TIMESTAMP_QUERY; + let required_features = OPTIONAL_FEATURES + .iter() + .fold(wgpu::Features::empty(), |features, (feature, _)| { + features | *feature + }) + & adapter.features(); let info = adapter.get_info(); let adapter_name = if info.name.is_empty() { format!("{:?} · {:?}", info.device_type, info.backend) @@ -65,6 +80,11 @@ impl Wgpu { width, height, timestamp_queries: required_features.contains(wgpu::Features::TIMESTAMP_QUERY), + features: OPTIONAL_FEATURES + .iter() + .filter(|(feature, _)| required_features.contains(*feature)) + .map(|(_, name)| *name) + .collect(), adapter: adapter_name, }) } diff --git a/core/worker.js b/core/worker.js index f0f1ee3..79aff00 100644 --- a/core/worker.js +++ b/core/worker.js @@ -15,7 +15,7 @@ addEventListener("message", async ({ data: message }) => { await core.initialize(message.canvas); const buffer = wasm.memory.buffer; if (!(buffer instanceof SharedArrayBuffer)) fail("WASM_MEMORY_NOT_SHARED"); - result = { buffer, rows: JSON.parse(core.rows()) }; + result = { buffer, rows: JSON.parse(core.rows()), features: JSON.parse(core.features()) }; } else { if (!core) fail("UNINITIALIZED"); switch (message?.type) { diff --git a/coredocs/guide/boot.md b/coredocs/guide/boot.md index a6326d8..f3cf6ac 100644 --- a/coredocs/guide/boot.md +++ b/coredocs/guide/boot.md @@ -75,7 +75,7 @@ const { buffer, rows } = await request( Requests are `{ type: string, request: any, ...fields }`. Success is `{ request, result }`; void operations have `result: undefined`. Failure is `{ request, error: string }`. The worker merely echoes `request`, so use unique values. Requests can complete out of order. An unknown message type returns `MESSAGE`; all non-init operations before init return `UNINITIALIZED`. -`init` is one-shot per worker. Its `canvas` **must** be an `OffscreenCanvas` and must appear in the transfer list. `arenaBytes` reaches a Rust `u32`; use an integer from 64 through `2^32 - 64` in practical JS calls. The result contains the shared WASM memory buffer and all initial row descriptors (currently `signals`). Do not transfer the `SharedArrayBuffer`. +`init` is one-shot per worker. Its `canvas` **must** be an `OffscreenCanvas` and must appear in the transfer list. `arenaBytes` reaches a Rust `u32`; use an integer from 64 through `2^32 - 64` in practical JS calls. The result contains the shared WASM memory buffer, all initial row descriptors (currently `signals`), and the optional device `features` that were granted. Do not transfer the `SharedArrayBuffer`. ## Shutdown diff --git a/coredocs/reference/errors.md b/coredocs/reference/errors.md index 8c3ea6d..30fc287 100644 --- a/coredocs/reference/errors.md +++ b/coredocs/reference/errors.md @@ -41,6 +41,7 @@ On row growth, consume the returned descriptor before the next access. Batch cre | `GRAPH_RESOURCE` | Empty/duplicate resource ID or texture planning serialization failure. | | `GRAPH_PIPELINE` | Empty/duplicate/missing or wrong-kind pipeline. | | `GRAPH_BUFFER_SYNC` | Buffer sync is not `frame` or `loadout`. | +| `GRAPH_INDIRECT` | Indirect pass also sets `draw`, names a buffer without `indirect` usage, has a row stride below 16/20 bytes, or its row range exceeds the array. | | `GRAPH_UNKNOWN` | Switch ID has not been compiled/stored. | | `GRAPH_ARRAY_UNKNOWN` | Buffer declaration names no existing row array at activation. | | `GRAPH_RESOURCE_UNKNOWN` | Pass binding or vertex/index resource cannot be resolved. | diff --git a/coredocs/reference/graph-schema.md b/coredocs/reference/graph-schema.md index b7398bf..ec889c5 100644 --- a/coredocs/reference/graph-schema.md +++ b/coredocs/reference/graph-schema.md @@ -96,6 +96,7 @@ Pass { vertexBuffers: VertexBinding[] = [], indexBuffer?: IndexBinding, draw: Draw = {}, + indirect?: IndirectBinding, dispatch: [u32,u32,u32] = [1,1,1] } Binding { group: u32 = 0, binding: u32 required, resource: string required, @@ -106,9 +107,12 @@ DepthAttachment { resource: string required, clear: number = 1, load: "clear"|"load" = "clear", store: "store"|"discard" = "store" } VertexBinding { slot: u32 = 0, resource: string required, offset: u64 = 0 } IndexBinding { resource: string required, format: "uint16"|"uint32" = "uint32", offset: u64 = 0 } +IndirectBinding { resource: string required, first: u32 = 0, count?: u32 } Draw { vertices: u32 = 3, indices: u32 = 0, instances: u32 = 1, firstVertex: u32 = 0, firstIndex: u32 = 0, baseVertex: i32 = 0, firstInstance: u32 = 0 } ``` -Render passes use `draw`; when `indexBuffer` exists, the indexed path uses `indices` (so set it nonzero), `firstIndex`, and `baseVertex`. Otherwise it uses `vertices` and `firstVertex`. Both use instance fields. Compute passes use `dispatch`. A pipeline ID is validated against the pass kind when the loadout is built. Binding offset/size and vertex/index offsets are bytes and must satisfy WebGPU alignment and range rules. Attachments require matching usages and formats; sampled/storage bindings likewise require matching texture usage, while WGSL determines binding type and visibility. +Render passes use `draw`; when `indexBuffer` exists, the indexed path uses `indices` (so set it nonzero), `firstIndex`, and `baseVertex`. Otherwise it uses `vertices` and `firstVertex`. Both use instance fields. Compute passes use `dispatch`. + +`indirect` replaces `draw` (which must stay at its defaults) with one draw command per row of a buffer declared with `indirect` usage: rows `first .. first + count`, where `count` defaults to every row through the end of the array at activation time. Each row begins with the WebGPU command — `[vertexCount, instanceCount, firstVertex, firstInstance]` for non-indexed passes, `[indexCount, instanceCount, firstIndex, baseVertex: i32, firstInstance]` when `indexBuffer` exists — so the row stride must be at least 16 or 20 bytes; the rest of the row is caller-owned. A zero `instanceCount` skips the row cheaply, so callers can keep spare rows and toggle draws without recompiling. Nonzero `firstInstance` requires the device feature `indirect-first-instance` (see `init` result `features`); without it WebGPU silently draws nothing for that command. A pipeline ID is validated against the pass kind when the loadout is built. Binding offset/size and vertex/index offsets are bytes and must satisfy WebGPU alignment and range rules. Attachments require matching usages and formats; sampled/storage bindings likewise require matching texture usage, while WGSL determines binding type and visibility. diff --git a/coredocs/reference/worker.md b/coredocs/reference/worker.md index 7544ae9..a3e987f 100644 --- a/coredocs/reference/worker.md +++ b/coredocs/reference/worker.md @@ -4,7 +4,7 @@ All fields below are top-level beside `type` and `request`. Except for `init` an | `type` | Fields | Transfer list | Success `result` | Constraints / effects | |---|---|---|---|---| -| `init` | `canvas: OffscreenCanvas`, `arenaBytes: number` | `[canvas]` | `{ buffer: SharedArrayBuffer, rows: Descriptor[] }` | Once only; initializes WASM/WebGPU. | +| `init` | `canvas: OffscreenCanvas`, `arenaBytes: number` | `[canvas]` | `{ buffer: SharedArrayBuffer, rows: Descriptor[], features: string[] }` | Once only; initializes WASM/WebGPU. `features` lists optional device features that were available (`timestamp-query`, `indirect-first-instance`). | | `create-rows` | `name`, `rows`, `stride`, `format` | — | descriptor | Nonempty name; rows > 0; stride ≥16 and multiple of 16; format f32/u32/i32. Existing name may only retain format/stride and grow. | | `create-rows-batch` | `rows: Array<{name,rows,stride,format}>` | — | descriptor array in input order | Nonempty. Sequential, **not transactional**: earlier creations can survive a later failure. Active GPU resources refresh once after successful batch. | | `delete-rows` | `name` | — | `undefined` | Not `signals`, not active slots, not referenced by active graph. | @@ -39,7 +39,7 @@ When enabled and supported, the worker polls every 250 ms and may send an unsoli } ``` -One pass timing corresponds to a compiled execution; compatible adjacent render passes may be merged and labeled together. Samples are throttled and asynchronous, so they are diagnostics, not one event per frame. Disabling clears pending published statistics and the worker polling interval. +One pass timing corresponds to a compiled execution; compatible adjacent render passes are merged into one execution labelled `first … last (N draws)`. Samples are throttled and asynchronous, so they are diagnostics, not one event per frame. Disabling clears pending published statistics and the worker polling interval. ## Texture lifecycle