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:
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
true
|
||||
} else {
|
||||
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 + instances,
|
||||
pass.draw.first_instance..pass.draw.first_instance + pass.draw.instances,
|
||||
);
|
||||
} else {
|
||||
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;
|
||||
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 {
|
||||
|
||||
@@ -192,10 +192,23 @@ pub struct Pass {
|
||||
pub index_buffer: Option<IndexBinding>,
|
||||
#[serde(default)]
|
||||
pub draw: Draw,
|
||||
pub indirect: Option<IndirectBinding>,
|
||||
#[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<u32>,
|
||||
}
|
||||
|
||||
#[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()
|
||||
}
|
||||
}
|
||||
|
||||
+21
-1
@@ -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<Self, String> {
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
+1
-1
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user