core: indirect draw passes, device feature list, drop handles-specific bundle heuristics

Amp-Thread-ID: https://ampcode.com/threads/T-01a0b2dd-db32-712d-99ce-12ce4a86d979
Co-authored-by: Heaust Azure <heaust.azure@gmail.com>
This commit is contained in:
Amp
2026-09-18 05:31:11 +00:00
co-authored by heaust
parent dda602c4a2
commit 49c80a209e
11 changed files with 123 additions and 56 deletions
+4 -3
View File
@@ -129,6 +129,7 @@ function deserialize(serialized: string) {
export class YawnCore { export class YawnCore {
readonly ready = Promise.resolve(); readonly ready = Promise.resolve();
readonly features = new Set(["timestamp-query", "indirect-first-instance"]);
readonly graphs: any[] = []; readonly graphs: any[] = [];
readonly serializedGraphs: string[] = []; readonly serializedGraphs: string[] = [];
readonly loadouts: string[] = []; readonly loadouts: string[] = [];
@@ -171,11 +172,11 @@ export class YawnCore {
return Promise.resolve(); return Promise.resolve();
} }
allocateObject(name: string) { allocateObject(name: string, count = 1) {
const id = this.#nextObjects.get(name) ?? 0; const id = this.#nextObjects.get(name) ?? 0;
this.#nextObjects.set(name, id + 1); this.#nextObjects.set(name, id + count);
const rows = this.array(name); const rows = this.array(name);
rows.resize(id + 1); rows.resize(id + count);
return Promise.resolve(id); return Promise.resolve(id);
} }
+3
View File
@@ -48,6 +48,8 @@ export class YawnCore {
#pending = new Map(); #pending = new Map();
#profileListeners = new Set(); #profileListeners = new Set();
#next = 1; #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 } = {}) { constructor(canvas, { arenaBytes = 64 * 1024 * 1024, debug = false, workerFactory } = {}) {
if (!canvas) throw new TypeError("canvas is required"); if (!canvas) throw new TypeError("canvas is required");
@@ -62,6 +64,7 @@ export class YawnCore {
const offscreen = canvas.transferControlToOffscreen?.() ?? canvas; const offscreen = canvas.transferControlToOffscreen?.() ?? canvas;
this.ready = this.#request("init", { canvas: offscreen, arenaBytes }, [offscreen]).then(async result => { this.ready = this.#request("init", { canvas: offscreen, arenaBytes }, [offscreen]).then(async result => {
this.#buffer = result.buffer; this.#buffer = result.buffer;
this.features = new Set(result.features);
for (const descriptor of result.rows) this.#arrays.set( for (const descriptor of result.rows) this.#arrays.set(
descriptor.name, descriptor.name,
new SharedRows(this.#buffer, descriptor), new SharedRows(this.#buffer, descriptor),
+6
View File
@@ -87,6 +87,12 @@ impl Core {
serde_json::to_string(&self.data.borrow().descriptors()).unwrap() 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( pub fn create_rows(
&self, &self,
name: String, name: String,
+46 -39
View File
@@ -212,22 +212,14 @@ impl GpuResources {
for execution in &graph.executions { for execution in &graph.executions {
let compiled = match execution { let compiled = match execution {
Execution::Render(passes) => { 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 { GpuPass::Render {
label: if passes.len() == 1 { label: if passes.len() == 1 {
graph.passes[passes[0]].id.clone() format!("{first} ({draws} draws)")
} else if passes
.iter()
.all(|index| graph.passes[*index].id.starts_with("forward-"))
{
format!("Forward ({draws} draws)")
} else if passes
.iter()
.all(|index| graph.passes[*index].id.starts_with("depth-"))
{
format!("Depth ({draws} draws)")
} else { } else {
format!("Render ({draws} draws)") format!("{first} … {last} ({draws} draws)")
}, },
first: passes[0], first: passes[0],
last: *passes.last().unwrap(), last: *passes.last().unwrap(),
@@ -405,6 +397,7 @@ impl GpuResources {
graph: &RenderGraph, graph: &RenderGraph,
passes: &[usize], passes: &[usize],
gpu: &Wgpu, gpu: &Wgpu,
data: &RenderData,
) -> Result<(wgpu::RenderBundle, usize), String> { ) -> Result<(wgpu::RenderBundle, usize), String> {
let pass = &graph.passes[passes[0]]; let pass = &graph.passes[passes[0]];
let declaration = graph let declaration = graph
@@ -441,9 +434,8 @@ impl GpuResources {
let mut previous_pipeline = None; let mut previous_pipeline = None;
let mut previous_bindings: Option<&[Binding]> = None; let mut previous_bindings: Option<&[Binding]> = None;
let mut draws = 0; let mut draws = 0;
let mut at = 0; for &at in passes {
while at < passes.len() { let pass = &graph.passes[at];
let pass = &graph.passes[passes[at]];
let pipeline = self let pipeline = self
.render_pipelines .render_pipelines
.get(&pass.pipeline) .get(&pass.pipeline)
@@ -469,7 +461,7 @@ impl GpuResources {
.buffer; .buffer;
encoder.set_vertex_buffer(binding.slot, buffer.slice(binding.offset..)); 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 let buffer = &self
.buffers .buffers
.get(&binding.resource) .get(&binding.resource)
@@ -479,26 +471,53 @@ impl GpuResources {
buffer.slice(binding.offset..), buffer.slice(binding.offset..),
parse(&binding.format, "GRAPH_INDEX_FORMAT")?, parse(&binding.format, "GRAPH_INDEX_FORMAT")?,
); );
let mut instances = pass.draw.instances; true
while at + 1 < passes.len() } else {
&& can_instance(pass, &graph.passes[passes[at + 1]], instances) false
{ };
at += 1; match (&pass.indirect, indexed) {
instances += graph.passes[passes[at]].draw.instances; (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( encoder.draw_indexed(
pass.draw.first_index..pass.draw.first_index + pass.draw.indices, pass.draw.first_index..pass.draw.first_index + pass.draw.indices,
pass.draw.base_vertex, pass.draw.base_vertex,
pass.draw.first_instance..pass.draw.first_instance + instances, pass.draw.first_instance..pass.draw.first_instance + pass.draw.instances,
); );
} else { draws += 1;
}
(None, false) => {
encoder.draw( encoder.draw(
pass.draw.first_vertex..pass.draw.first_vertex + pass.draw.vertices, pass.draw.first_vertex..pass.draw.first_vertex + pass.draw.vertices,
pass.draw.first_instance..pass.draw.first_instance + pass.draw.instances, pass.draw.first_instance..pass.draw.first_instance + pass.draw.instances,
); );
}
draws += 1; draws += 1;
at += 1; }
}
} }
Ok(( Ok((
encoder.finish(&wgpu::RenderBundleDescriptor { 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 { impl GpuPass {
fn label(&self) -> &str { fn label(&self) -> &str {
match self { match self {
+25
View File
@@ -192,10 +192,23 @@ pub struct Pass {
pub index_buffer: Option<IndexBinding>, pub index_buffer: Option<IndexBinding>,
#[serde(default)] #[serde(default)]
pub draw: Draw, pub draw: Draw,
pub indirect: Option<IndirectBinding>,
#[serde(default = "dispatch")] #[serde(default = "dispatch")]
pub dispatch: [u32; 3], 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<u32>,
}
#[derive(Clone, Deserialize, PartialEq, Eq)] #[derive(Clone, Deserialize, PartialEq, Eq)]
pub struct Binding { pub struct Binding {
#[serde(default)] #[serde(default)]
@@ -344,6 +357,17 @@ impl RenderGraph {
"compute" => _ = compute_pipelines.insert(pass.pipeline.clone()), "compute" => _ = compute_pipelines.insert(pass.pipeline.clone()),
_ => return Err("GRAPH_PASS"), _ => 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() { for id in pass.resources() {
used.insert(id.to_owned()); used.insert(id.to_owned());
lifetimes lifetimes
@@ -532,6 +556,7 @@ impl Pass {
.iter() .iter()
.map(|value| value.resource.as_str()), .map(|value| value.resource.as_str()),
) )
.chain(self.indirect.iter().map(|value| value.resource.as_str()))
.collect() .collect()
} }
} }
+21 -1
View File
@@ -8,9 +8,19 @@ pub struct Wgpu {
pub width: u32, pub width: u32,
pub height: u32, pub height: u32,
pub timestamp_queries: bool, pub timestamp_queries: bool,
/// Optional features the device was created with, in WebGPU spelling.
pub features: Vec<&'static str>,
pub adapter: String, 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 { impl Wgpu {
pub async fn new(canvas: web_sys::OffscreenCanvas) -> Result<Self, String> { pub async fn new(canvas: web_sys::OffscreenCanvas) -> Result<Self, String> {
let width = canvas.width(); let width = canvas.width();
@@ -33,7 +43,12 @@ impl Wgpu {
}) })
.await .await
.map_err(|_| "WEBGPU_UNAVAILABLE")?; .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 info = adapter.get_info();
let adapter_name = if info.name.is_empty() { let adapter_name = if info.name.is_empty() {
format!("{:?} · {:?}", info.device_type, info.backend) format!("{:?} · {:?}", info.device_type, info.backend)
@@ -65,6 +80,11 @@ impl Wgpu {
width, width,
height, height,
timestamp_queries: required_features.contains(wgpu::Features::TIMESTAMP_QUERY), 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, adapter: adapter_name,
}) })
} }
+1 -1
View File
@@ -15,7 +15,7 @@ addEventListener("message", async ({ data: message }) => {
await core.initialize(message.canvas); await core.initialize(message.canvas);
const buffer = wasm.memory.buffer; const buffer = wasm.memory.buffer;
if (!(buffer instanceof SharedArrayBuffer)) fail("WASM_MEMORY_NOT_SHARED"); 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 { } else {
if (!core) fail("UNINITIALIZED"); if (!core) fail("UNINITIALIZED");
switch (message?.type) { switch (message?.type) {
+1 -1
View File
@@ -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`. 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 ## Shutdown
+1
View File
@@ -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_RESOURCE` | Empty/duplicate resource ID or texture planning serialization failure. |
| `GRAPH_PIPELINE` | Empty/duplicate/missing or wrong-kind pipeline. | | `GRAPH_PIPELINE` | Empty/duplicate/missing or wrong-kind pipeline. |
| `GRAPH_BUFFER_SYNC` | Buffer sync is not `frame` or `loadout`. | | `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_UNKNOWN` | Switch ID has not been compiled/stored. |
| `GRAPH_ARRAY_UNKNOWN` | Buffer declaration names no existing row array at activation. | | `GRAPH_ARRAY_UNKNOWN` | Buffer declaration names no existing row array at activation. |
| `GRAPH_RESOURCE_UNKNOWN` | Pass binding or vertex/index resource cannot be resolved. | | `GRAPH_RESOURCE_UNKNOWN` | Pass binding or vertex/index resource cannot be resolved. |
+5 -1
View File
@@ -96,6 +96,7 @@ Pass {
vertexBuffers: VertexBinding[] = [], vertexBuffers: VertexBinding[] = [],
indexBuffer?: IndexBinding, indexBuffer?: IndexBinding,
draw: Draw = {}, draw: Draw = {},
indirect?: IndirectBinding,
dispatch: [u32,u32,u32] = [1,1,1] dispatch: [u32,u32,u32] = [1,1,1]
} }
Binding { group: u32 = 0, binding: u32 required, resource: string required, 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" } load: "clear"|"load" = "clear", store: "store"|"discard" = "store" }
VertexBinding { slot: u32 = 0, resource: string required, offset: u64 = 0 } VertexBinding { slot: u32 = 0, resource: string required, offset: u64 = 0 }
IndexBinding { resource: string required, format: "uint16"|"uint32" = "uint32", 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, Draw { vertices: u32 = 3, indices: u32 = 0, instances: u32 = 1,
firstVertex: u32 = 0, firstIndex: u32 = 0, baseVertex: i32 = 0, firstVertex: u32 = 0, firstIndex: u32 = 0, baseVertex: i32 = 0,
firstInstance: u32 = 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.
+2 -2
View File
@@ -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 | | `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` | `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. | | `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. | | `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 ## Texture lifecycle