core: manual buffer sync with sync-rows, reuse unchanged buffers and pipelines across activations

Also lets the screenshot suite point at another reference directory and settle longer.

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:53:44 +00:00
co-authored by heaust
parent 49c80a209e
commit 31d3ae21ff
15 changed files with 185 additions and 48 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ export type GraphBuffer = {
id: string; id: string;
array: string; array: string;
usage?: string[]; usage?: string[];
sync?: "frame" | "loadout"; sync?: "frame" | "manual";
}; };
export type GraphTexture = { export type GraphTexture = {
+4 -3
View File
@@ -990,6 +990,7 @@ export class Scene {
this.#geometry.get(geometry) ?? this.#geometry.get(geometry) ??
this.#geometry.set(geometry, new Map()).get(geometry)! this.#geometry.set(geometry, new Map()).get(geometry)!
).set(kind, data); ).set(kind, data);
await this.core.syncRows(name, 0, rowCount);
if (updateGraph) await this.updateRenderGraph(); if (updateGraph) await this.updateRenderGraph();
} }
@@ -1248,7 +1249,7 @@ export class Scene {
id: vertex, id: vertex,
array: `geometry.${mesh.geometryId}.positions`, array: `geometry.${mesh.geometryId}.positions`,
usage: ["vertex"], usage: ["vertex"],
sync: "loadout", sync: "manual",
}); });
const normal = `geometry-${mesh.geometryId}-normals`; const normal = `geometry-${mesh.geometryId}-normals`;
const uv = `geometry-${mesh.geometryId}-uvs`; const uv = `geometry-${mesh.geometryId}-uvs`;
@@ -1272,7 +1273,7 @@ export class Scene {
id, id,
array: `geometry.${mesh.geometryId}.${kind}`, array: `geometry.${mesh.geometryId}.${kind}`,
usage: ["vertex"], usage: ["vertex"],
sync: "loadout", sync: "manual",
}); });
const indexed = mesh.indexCount > 0; const indexed = mesh.indexCount > 0;
if (indexed) if (indexed)
@@ -1280,7 +1281,7 @@ export class Scene {
id: `geometry-${mesh.geometryId}-indices`, id: `geometry-${mesh.geometryId}-indices`,
array: `geometry.${mesh.geometryId}.indices`, array: `geometry.${mesh.geometryId}.indices`,
usage: ["index"], usage: ["index"],
sync: "loadout", sync: "manual",
}); });
const draws = const draws =
indexed && mesh.faceMaterials.size indexed && mesh.faceMaterials.size
+4
View File
@@ -184,6 +184,10 @@ export class YawnCore {
return Promise.resolve(); return Promise.resolve();
} }
syncRows() {
return Promise.resolve();
}
array(name: string) { array(name: string) {
const rows = this.#arrays.get(name); const rows = this.#arrays.get(name);
if (!rows) throw new Error(`UNKNOWN_ARRAY: ${name}`); if (!rows) throw new Error(`UNKNOWN_ARRAY: ${name}`);
@@ -9,6 +9,11 @@ uses headed Chromium on a private Xvfb display and SwiftShader WebGPU at a fixed
The first run downloads roughly 215 MB of GLBs into The first run downloads roughly 215 MB of GLBs into
`~/.cache/yawn/gltf-sample-models`. Set `YAWN_GLTF_FIXTURES` to use another `~/.cache/yawn/gltf-sample-models`. Set `YAWN_GLTF_FIXTURES` to use another
cache directory, or `YAWN_SCREENSHOT_MODELS=Box,BoomBox` to run a subset. cache directory, or `YAWN_SCREENSHOT_MODELS=Box,BoomBox` to run a subset.
`YAWN_SCREENSHOT_BASELINES` points at another reference directory (for example
one captured from a known-good commit on a machine whose software renderer does
not match the reviewed references), and `YAWN_SCREENSHOT_SETTLE_MS` (default
1200) is how long the viewer gets to settle after the model loads before the
capture; raise it on slow software renderers.
Failed full-size captures and visual diffs are written to `output/`. Failed full-size captures and visual diffs are written to `output/`.
To intentionally replace references after reviewing full-size renders, run: To intentionally replace references after reviewing full-size renders, run:
+6 -3
View File
@@ -25,7 +25,10 @@ import { promisify } from "node:util";
const execute = promisify(execFile); const execute = promisify(execFile);
const directory = dirname(fileURLToPath(import.meta.url)); const directory = dirname(fileURLToPath(import.meta.url));
const root = resolve(directory, "../../../.."); const root = resolve(directory, "../../../..");
const baselines = join(directory, "baselines"); const baselines = resolve(
process.env.YAWN_SCREENSHOT_BASELINES ?? join(directory, "baselines"),
);
const settleMilliseconds = Number(process.env.YAWN_SCREENSHOT_SETTLE_MS ?? 1200);
const output = resolve( const output = resolve(
process.env.YAWN_SCREENSHOT_OUTPUT ?? join(directory, "output"), process.env.YAWN_SCREENSHOT_OUTPUT ?? join(directory, "output"),
); );
@@ -44,7 +47,7 @@ const update = process.env.UPDATE_SCREENSHOTS === "1";
const session = `yawn-gltf-screenshots-${process.pid}`; const session = `yawn-gltf-screenshots-${process.pid}`;
const temporary = await mkdtemp(join(tmpdir(), "yawn-gltf-screenshots-")); const temporary = await mkdtemp(join(tmpdir(), "yawn-gltf-screenshots-"));
const imageSize = { width: 250, height: 180 }; const imageSize = { width: 250, height: 180 };
const allModels = (await readdir(baselines)) const allModels = (await readdir(join(directory, "baselines")))
.filter((name) => name.endsWith(".png") && name !== "initial.png") .filter((name) => name.endsWith(".png") && name !== "initial.png")
.map((name) => basename(name, ".png")) .map((name) => basename(name, ".png"))
.sort() .sort()
@@ -366,7 +369,7 @@ for (const model of models) {
); );
await browser("upload", "#file-picker", model.path); await browser("upload", "#file-picker", model.path);
await browser("wait", "--fn", "window.__yawnScreenshotReady === true"); await browser("wait", "--fn", "window.__yawnScreenshotReady === true");
await browser("wait", "1200"); await browser("wait", String(settleMilliseconds));
const state = await browser( const state = await browser(
"eval", "eval",
+6
View File
@@ -113,6 +113,12 @@ export class YawnCore {
return this.#request("delete-object", { name, id }); return this.#request("delete-object", { name, id });
} }
/** Uploads rows `first .. first + count` of `name` to every active GPU buffer bound to that array. */
async syncRows(name, first, count) {
await this.ready;
return this.#request("sync-rows", { name, first, count });
}
async compileGraph(serialized) { async compileGraph(serialized) {
await this.ready; await this.ready;
return this.#request("compile-graph", { serialized }); return this.#request("compile-graph", { serialized });
+14
View File
@@ -186,6 +186,20 @@ impl Core {
Ok(()) Ok(())
} }
/// Uploads rows `first .. first + count` of `name` to every active GPU buffer bound to it.
pub fn sync_rows(&self, name: &str, first: u32, count: u32) -> Result<(), JsError> {
let gpu = self.gpu.borrow();
let gpu = gpu
.as_ref()
.ok_or_else(|| JsError::new("WEBGPU_UNINITIALIZED"))?;
self.store
.borrow()
.sync_rows(name, first, count, gpu, &self.data.borrow())
.map_err(|error| JsError::new(&error))?;
self.data.borrow_mut().mark_dirty();
Ok(())
}
pub fn compile_graph(&self, source: &str) -> Result<String, JsError> { pub fn compile_graph(&self, source: &str) -> Result<String, JsError> {
let graph = compiler::compile(source).map_err(JsError::new)?; let graph = compiler::compile(source).map_err(JsError::new)?;
Ok(self.store.borrow_mut().save(graph)) Ok(self.store.borrow_mut().save(graph))
+84 -8
View File
@@ -9,6 +9,7 @@ use serde_json::Value;
use crate::gpu::Wgpu; use crate::gpu::Wgpu;
use crate::graph::{Binding, Execution, Extent, Pass, RenderGraph, RenderPipeline, Texture}; use crate::graph::{Binding, Execution, Extent, Pass, RenderGraph, RenderPipeline, Texture};
use crate::render_data::RenderData; use crate::render_data::RenderData;
use crate::store::Loadout;
pub struct GpuResources { pub struct GpuResources {
pub buffers: HashMap<String, GpuBuffer>, pub buffers: HashMap<String, GpuBuffer>,
@@ -38,6 +39,7 @@ pub struct ProfileMap {
pub struct GpuBuffer { pub struct GpuBuffer {
pub buffer: wgpu::Buffer, pub buffer: wgpu::Buffer,
pub source: String, pub source: String,
pub usage: wgpu::BufferUsages,
pub sync_each_frame: bool, pub sync_each_frame: bool,
} }
@@ -65,28 +67,48 @@ pub enum GpuPass {
} }
impl GpuResources { impl GpuResources {
/// Builds the GPU side of `graph`. Buffers whose source array, size, and usage are unchanged
/// and pipelines whose declaration is unchanged are taken over from `previous` instead of being
/// recreated and re-uploaded; bind groups and bundles are always rebuilt.
pub fn activate( pub fn activate(
graph: &RenderGraph, graph: &RenderGraph,
gpu: &Wgpu, gpu: &Wgpu,
data: &RenderData, data: &RenderData,
previous: Option<&Self>, previous: Option<&Loadout>,
) -> Result<Self, String> { ) -> Result<Self, String> {
let mut buffers = HashMap::new(); let mut buffers = HashMap::new();
for source in &graph.resources.buffers { for source in &graph.resources.buffers {
let rows = data.rows(&source.array).ok_or("GRAPH_ARRAY_UNKNOWN")?; let rows = data.rows(&source.array).ok_or("GRAPH_ARRAY_UNKNOWN")?;
let size = u64::from(rows.bytes.max(4));
let usage = buffer_usage(&source.usage)?;
let reused = previous
.and_then(|previous| previous.resources.buffers.get(&source.id))
.filter(|buffer| {
buffer.source == source.array
&& buffer.usage == usage
&& buffer.buffer.size() == size
})
.map(|buffer| buffer.buffer.clone());
let buffer = match reused {
Some(buffer) => buffer,
None => {
let buffer = gpu.device.create_buffer(&wgpu::BufferDescriptor { let buffer = gpu.device.create_buffer(&wgpu::BufferDescriptor {
label: Some(&source.id), label: Some(&source.id),
size: u64::from(rows.bytes.max(4)), size,
usage: buffer_usage(&source.usage)?, usage,
mapped_at_creation: false, mapped_at_creation: false,
}); });
gpu.queue gpu.queue
.write_buffer(&buffer, 0, data.bytes(&source.array).unwrap()); .write_buffer(&buffer, 0, data.bytes(&source.array).unwrap());
buffer
}
};
buffers.insert( buffers.insert(
source.id.clone(), source.id.clone(),
GpuBuffer { GpuBuffer {
buffer, buffer,
source: source.array.clone(), source: source.array.clone(),
usage,
sync_each_frame: source.sync == "frame", sync_each_frame: source.sync == "frame",
}, },
); );
@@ -102,11 +124,12 @@ impl GpuResources {
let key = source.key()?; let key = source.key()?;
if !source.transient { if !source.transient {
if let Some(texture) = previous if let Some(texture) = previous
.and_then(|resources| { .and_then(|previous| {
resources previous
.resources
.texture_slots .texture_slots
.get(&source.id) .get(&source.id)
.map(|slot| &resources.textures[*slot]) .map(|slot| &previous.resources.textures[*slot])
}) })
.filter(|texture| texture.key == key) .filter(|texture| texture.key == key)
{ {
@@ -147,7 +170,23 @@ impl GpuResources {
.render .render
.iter() .iter()
.map(|source| { .map(|source| {
create_render_pipeline(source, gpu).map(|pipeline| (source.id.clone(), pipeline)) let reused = previous.and_then(|previous| {
previous
.graph
.pipelines
.render
.iter()
.find(|candidate| candidate.same_as(source))
.and_then(|candidate| {
previous.resources.render_pipelines.get(&candidate.id)
})
.cloned()
});
match reused {
Some(pipeline) => Ok(pipeline),
None => create_render_pipeline(source, gpu),
}
.map(|pipeline| (source.id.clone(), pipeline))
}) })
.collect::<Result<HashMap<_, _>, _>>()?; .collect::<Result<HashMap<_, _>, _>>()?;
let compute_pipelines = graph let compute_pipelines = graph
@@ -155,6 +194,21 @@ impl GpuResources {
.compute .compute
.iter() .iter()
.map(|source| { .map(|source| {
let reused = previous.and_then(|previous| {
previous
.graph
.pipelines
.compute
.iter()
.find(|candidate| candidate.same_as(source))
.and_then(|candidate| {
previous.resources.compute_pipelines.get(&candidate.id)
})
.cloned()
});
if let Some(pipeline) = reused {
return Ok::<_, String>((source.id.clone(), pipeline));
}
let module = gpu let module = gpu
.device .device
.create_shader_module(wgpu::ShaderModuleDescriptor { .create_shader_module(wgpu::ShaderModuleDescriptor {
@@ -171,7 +225,7 @@ impl GpuResources {
compilation_options: Default::default(), compilation_options: Default::default(),
cache: None, cache: None,
}); });
Ok::<_, String>((source.id.clone(), pipeline)) Ok((source.id.clone(), pipeline))
}) })
.collect::<Result<HashMap<_, _>, _>>()?; .collect::<Result<HashMap<_, _>, _>>()?;
@@ -251,6 +305,28 @@ impl GpuResources {
Ok(resources) Ok(resources)
} }
/// Copies rows `first .. first + count` of a source array into every GPU buffer bound to it.
pub fn sync_rows(
&self,
name: &str,
first: u32,
count: u32,
gpu: &Wgpu,
data: &RenderData,
) -> Result<(), String> {
let rows = data.rows(name).ok_or("ROWS_UNKNOWN")?;
let end = first
.checked_add(count)
.filter(|end| *end <= rows.rows)
.ok_or("ROW_RANGE")?;
let start = (first * rows.stride) as usize;
let bytes = &data.bytes(name).unwrap()[start..(end * rows.stride) as usize];
for buffer in self.buffers.values().filter(|buffer| buffer.source == name) {
gpu.queue.write_buffer(&buffer.buffer, start as u64, bytes);
}
Ok(())
}
pub fn texture_view(&self, id: &str) -> Option<&wgpu::TextureView> { pub fn texture_view(&self, id: &str) -> Option<&wgpu::TextureView> {
self.texture_slots self.texture_slots
.get(id) .get(id)
+26 -8
View File
@@ -85,7 +85,7 @@ pub struct PipelineDeclarations {
pub compute: Vec<ComputePipeline>, pub compute: Vec<ComputePipeline>,
} }
#[derive(Clone, Deserialize)] #[derive(Clone, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct RenderPipeline { pub struct RenderPipeline {
pub id: String, pub id: String,
@@ -102,7 +102,7 @@ pub struct RenderPipeline {
pub multisample: Value, pub multisample: Value,
} }
#[derive(Clone, Deserialize)] #[derive(Clone, Deserialize, PartialEq, Eq)]
pub struct ComputePipeline { pub struct ComputePipeline {
pub id: String, pub id: String,
pub code: String, pub code: String,
@@ -110,7 +110,7 @@ pub struct ComputePipeline {
pub entry: String, pub entry: String,
} }
#[derive(Clone, Deserialize)] #[derive(Clone, Deserialize, PartialEq, Eq)]
pub struct VertexStage { pub struct VertexStage {
#[serde(default = "vertex_entry")] #[serde(default = "vertex_entry")]
pub entry: String, pub entry: String,
@@ -127,7 +127,7 @@ impl Default for VertexStage {
} }
} }
#[derive(Clone, Default, Deserialize)] #[derive(Clone, Default, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct VertexBuffer { pub struct VertexBuffer {
pub array_stride: u64, pub array_stride: u64,
@@ -137,7 +137,7 @@ pub struct VertexBuffer {
pub attributes: Vec<VertexAttribute>, pub attributes: Vec<VertexAttribute>,
} }
#[derive(Clone, Deserialize)] #[derive(Clone, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct VertexAttribute { pub struct VertexAttribute {
pub format: String, pub format: String,
@@ -145,7 +145,7 @@ pub struct VertexAttribute {
pub shader_location: u32, pub shader_location: u32,
} }
#[derive(Clone, Deserialize)] #[derive(Clone, Deserialize, PartialEq)]
pub struct FragmentStage { pub struct FragmentStage {
#[serde(default = "fragment_entry")] #[serde(default = "fragment_entry")]
pub entry: String, pub entry: String,
@@ -162,7 +162,7 @@ impl Default for FragmentStage {
} }
} }
#[derive(Clone, Deserialize)] #[derive(Clone, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct FragmentTarget { pub struct FragmentTarget {
pub format: String, pub format: String,
@@ -519,7 +519,7 @@ impl RenderGraph {
.resources .resources
.buffers .buffers
.iter() .iter()
.any(|buffer| !matches!(buffer.sync.as_str(), "frame" | "loadout")) .any(|buffer| !matches!(buffer.sync.as_str(), "frame" | "manual"))
{ {
return Err("GRAPH_BUFFER_SYNC"); return Err("GRAPH_BUFFER_SYNC");
} }
@@ -561,6 +561,24 @@ impl Pass {
} }
} }
impl RenderPipeline {
/// Same GPU pipeline regardless of declaration ID.
pub(crate) fn same_as(&self, other: &Self) -> bool {
self.code == other.code
&& self.vertex == other.vertex
&& self.fragment == other.fragment
&& self.primitive == other.primitive
&& self.depth_stencil == other.depth_stencil
&& self.multisample == other.multisample
}
}
impl ComputePipeline {
pub(crate) fn same_as(&self, other: &Self) -> bool {
self.code == other.code && self.entry == other.entry
}
}
impl Texture { impl Texture {
pub(crate) fn key(&self) -> Result<String, &'static str> { pub(crate) fn key(&self) -> Result<String, &'static str> {
let mut usage = self.usage.clone(); let mut usage = self.usage.clone();
+16 -12
View File
@@ -26,12 +26,7 @@ impl Store {
pub fn switch(&mut self, id: &str, gpu: &Wgpu, data: &RenderData) -> Result<(), String> { pub fn switch(&mut self, id: &str, gpu: &Wgpu, data: &RenderData) -> Result<(), String> {
let graph = self.graphs.get(id).ok_or("GRAPH_UNKNOWN")?.clone(); let graph = self.graphs.get(id).ok_or("GRAPH_UNKNOWN")?.clone();
let mut resources = GpuResources::activate( let mut resources = GpuResources::activate(&graph, gpu, data, self.active.as_ref())?;
&graph,
gpu,
data,
self.active.as_ref().map(|active| &active.resources),
)?;
for (name, levels) in &self.texture_sources { for (name, levels) in &self.texture_sources {
for (mip_level, image) in levels { for (mip_level, image) in levels {
if resources.needs_upload(name, *mip_level) { if resources.needs_upload(name, *mip_level) {
@@ -72,6 +67,20 @@ impl Store {
} }
} }
pub fn sync_rows(
&self,
name: &str,
first: u32,
count: u32,
gpu: &Wgpu,
data: &RenderData,
) -> Result<(), String> {
match &self.active {
Some(active) => active.resources.sync_rows(name, first, count, gpu, data),
None => Ok(()),
}
}
pub fn active_mut(&mut self) -> Option<&mut Loadout> { pub fn active_mut(&mut self) -> Option<&mut Loadout> {
self.active.as_mut() self.active.as_mut()
} }
@@ -104,12 +113,7 @@ impl Store {
return Ok(()); return Ok(());
}; };
let graph = active.graph.clone(); let graph = active.graph.clone();
let mut resources = GpuResources::activate( let mut resources = GpuResources::activate(&graph, gpu, data, self.active.as_ref())?;
&graph,
gpu,
data,
self.active.as_ref().map(|active| &active.resources),
)?;
for (name, levels) in &self.texture_sources { for (name, levels) in &self.texture_sources {
for (mip_level, image) in levels { for (mip_level, image) in levels {
if resources.needs_upload(name, *mip_level) { if resources.needs_upload(name, *mip_level) {
+3
View File
@@ -34,6 +34,9 @@ addEventListener("message", async ({ data: message }) => {
case "delete-object": case "delete-object":
core.delete_object(message.name, message.id); core.delete_object(message.name, message.id);
break; break;
case "sync-rows":
core.sync_rows(message.name, message.first ?? 0, message.count);
break;
case "compile-graph": case "compile-graph":
result = core.compile_graph(message.serialized); result = core.compile_graph(message.serialized);
break; break;
+5 -3
View File
@@ -73,8 +73,10 @@ async function replaceBundle(mutate, compileAndSwitch) {
The signals are invalidation state, not a lock or transaction boundary. If multiple threads can write one logical update, coordinate those writers so core cannot observe a partially updated value. Core marks lane 5 after object deletion, texture upload, and a successful switch, but direct SAB writes do not. Always publish lane 5 after direct data writes. The signals are invalidation state, not a lock or transaction boundary. If multiple threads can write one logical update, coordinate those writers so core cannot observe a partially updated value. Core marks lane 5 after object deletion, texture upload, and a successful switch, but direct SAB writes do not. Always publish lane 5 after direct data writes.
## Frame-sync versus loadout-sync ## Frame-sync versus manual-sync
A graph buffer's `sync` defaults to `"frame"`. Before each actual render, the full named row array is copied SAB → GPU, so ordinary value edits only require `sabDirty`. A graph buffer's `sync` defaults to `"frame"`. Before each actual render, the full named row array is copied SAB → GPU, so ordinary value edits only require `sabDirty`. Use it for small, frequently changing arrays (transforms, lights, draw commands).
`sync: "loadout"` is copied only when GPU resources are activated: on `switch-loadout`, and when core refreshes an active graph because a used row array grows/is recreated. Direct edits afterward do **not** reach that GPU buffer. To change loadout-synced content reliably, set `bundleDirty`, mutate, and switch a compiled replacement loadout (the replacement may use a newly compiled graph with the same schema). Structural changes affecting buffer size, bindings, offsets, draw ranges, passes, pipelines, or attachment/resource declarations also require bundle invalidation and a graph/loadout switch. This distinction matters because render commands are precompiled into render bundles. `sync: "manual"` is copied in full only when its GPU buffer is created: on the first `switch-loadout` that declares it, and whenever the source array grows/relocates or the declaration's usage changes. Later SAB edits reach the GPU only through `sync-rows { name, first, count }`, which uploads that row range into every active buffer bound to the array and marks the frame dirty. Use it for large, mostly static arrays such as vertex and index data: write the rows, then `sync-rows` exactly that span.
GPU resources survive graph switches when nothing about them changed: a buffer is reused when its declaration ID keeps the same source array, byte size, and usage; a render or compute pipeline is reused when a previous declaration had identical code and state (IDs may differ); non-transient textures with the same descriptor are kept. Bind groups and render bundles are always rebuilt. Structural changes affecting bindings, offsets, draw ranges, passes, pipelines, or attachment/resource declarations require bundle invalidation and a graph/loadout switch because render commands are precompiled into render bundles.
+1 -1
View File
@@ -40,7 +40,7 @@ On row growth, consume the returned descriptor before the next access. Batch cre
| `GRAPH_DEPENDENCY` / `GRAPH_CYCLE` | Missing `after` ID / dependency cycle. | | `GRAPH_DEPENDENCY` / `GRAPH_CYCLE` | Missing `after` ID / dependency cycle. |
| `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 `manual`. |
| `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_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. |
+3 -3
View File
@@ -23,7 +23,7 @@ Resource IDs must be nonempty and unique across buffers, textures, and samplers.
{ id: string required, { id: string required,
array: string required, // existing shared-row name at switch time array: string required, // existing shared-row name at switch time
usage: string[] = [], usage: string[] = [],
sync: "frame" | "loadout" = "frame" } sync: "frame" | "manual" = "frame" }
``` ```
Allowed usage names: `uniform`, `storage`, `vertex`, `index`, `indirect`, `copySrc`. `COPY_DST` is always added. Include every way the graph uses the buffer. The allocation is at least 4 bytes, but data copied is the row descriptor's full `bytes`. Allowed usage names: `uniform`, `storage`, `vertex`, `index`, `indirect`, `copySrc`. `COPY_DST` is always added. Include every way the graph uses the buffer. The allocation is at least 4 bytes, but data copied is the row descriptor's full `bytes`.
@@ -113,6 +113,6 @@ Draw { vertices: u32 = 3, indices: u32 = 0, instances: u32 = 1,
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`. 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.
`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. `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.
+1
View File
@@ -10,6 +10,7 @@ All fields below are top-level beside `type` and `request`. Except for `init` an
| `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. |
| `allocate-object` | `name`, `count: u32 = 1` | — | `{ id, rows: descriptor }` | Not `signals`; `count` > 0; reserves `count` contiguous rows starting at `id`; may grow/relocate. | | `allocate-object` | `name`, `count: u32 = 1` | — | `{ id, rows: descriptor }` | Not `signals`; `count` > 0; reserves `count` contiguous rows starting at `id`; may grow/relocate. |
| `delete-object` | `name`, `id: u32` | — | `undefined` | ID must be the first row of an active span; the whole span is zeroed. | | `delete-object` | `name`, `id: u32` | — | `undefined` | ID must be the first row of an active span; the whole span is zeroed. |
| `sync-rows` | `name`, `first: u32 = 0`, `count: u32` | — | `undefined` | Uploads rows `first .. first + count` of the array into every active GPU buffer bound to it (any `sync`) and sets `sabDirty`. `ROW_RANGE` when the span exceeds the array. No-op without an active loadout. |
| `compile-graph` | `serialized: string` | — | graph ID string | Parses and stores; same ID replaces previously stored graph. Does not activate it. | | `compile-graph` | `serialized: string` | — | graph ID string | Parses and stores; same ID replaces previously stored graph. Does not activate it. |
| `switch-loadout` | `id` | — | `undefined` | Builds GPU resources and activates stored graph. Clears `bundleDirty`, sets `sabDirty`. | | `switch-loadout` | `id` | — | `undefined` | Builds GPU resources and activates stored graph. Clears `bundleDirty`, sets `sabDirty`. |
| `upload-texture` | `name`, `mipLevel: u32`, `image: ImageBitmap` | `[image]` | `undefined` | Uploads immediately if active graph has the texture and caches source for future switches. Source extent must fit destination/mip. Sets dirty. | | `upload-texture` | `name`, `mipLevel: u32`, `image: ImageBitmap` | `[image]` | `undefined` | Uploads immediately if active graph has the texture and caches source for future switches. Source extent must fit destination/mip. Sets dirty. |