Your application owns the window, the frame loop, and the GPU objects
that outlive a frame. A script owns encoding. The script programs
against the WebGPU JS API shape — requestAdapter, createBuffer,
beginRenderPass — and never names the C facade below it. The GPU
work reads like WebGPU and reloads like a script.
A Rust host gets one thing a C host cannot get: the development
tier runs in your process. ReloadSession compiles the program in
memory, and your host calls its entry directly. No C compiler runs,
and no generated artifact reaches the disk.
Read this first, plainly:
- You supply the backend. Every command below needs
SUBSCRIPT_GPU_BACKEND_LIB_DIR. The variable points at a directory that holds a yawgpu build. On macOS that islibyawgpu.dylibwithlibtint_shim.dylibbeside it. The static library is unused. This repository neither vendors nor builds one. - The generation chain is fixed to this workspace. It reads
engine/src/lib.rsand writes eight files to fixed paths here. The "you write it" row below means that file, in this checkout. - Lifetimes are manual. Create-owns plus explicit release, and no finalizers. Your host releases what your host created.
- Scope. This document is enough to run the two examples this repository ships. It is not enough to build a host in your own crate. "What this document does not cover" says why.
Everything below is the committed, gate-pinned pair
programs/a27-host-compute.ts and
programs/a28-host-triangle.ts.
The outputs come from a run of them.
| Artifact | What it is | Who writes it |
|---|---|---|
engine/src/lib.rs |
your host's script-visible surface, as extern "C" functions |
you |
engine/engine.h |
the C header for that surface | generated |
mirror/engine.generated.d.ts |
the ambient declarations scripts see | generated |
mirror/sgpu.generated.d.ts |
the facade mirror, which is internal plumbing | generated |
api/webgpu.ts |
the WebGPU-shaped API layer a script imports | generated |
cargo run -p subscript-gpu-codegen --offline writes all five, plus
the facade, its Rust surface, and the dev-JIT symbol table. Every
output is gated byte-identical, so no output can drift from its
source.
| Example | Who creates the GPU objects | What the host does |
|---|---|---|
compute_host over a27 |
the script | step async, and nothing else |
triangle_host over a28 |
the host | own the device and the frame target, pump the instance, drive frames |
Run them:
export SUBSCRIPT_GPU_BACKEND_LIB_DIR=<directory that holds the backend library>
cargo run -p subscript-gpu-harness --bin compute_host --features backend-yawgpu
cargo run -p subscript-gpu-harness --bin triangle_host --features backend-yawgpuA script that owns everything needs no host support beyond async.
The entry is export async function main(). It suspends at the first
await and only a step resumes it, so the host drains the pending
work after the call:
let mut actions = Vec::new();
actions.push(HostAction::AsyncStep);
session
.async_step()
.map_err(|error| format!("initial async step: {error}"))?;
actions.push(HostAction::ScriptEntry);
session
.call_main()
.map_err(|error| format!("call script entry: {error}"))?;
while session.async_pending() != 0 {
actions.push(HostAction::AsyncStep);
session
.async_step()
.map_err(|error| format!("resume async entry: {error}"))?;
}This host pumps no GPU events, because it owns no instance. The API
layer pumps the script's own instance inside every await. A host
pumps what it owns, and nothing else.
session.take_output() drains what print wrote. The run prints:
dispatch:encoded-workgroups=4
completion:submitted=true:mapped=true
readback:observed=1,2,3,4
resources:released
The read-back bytes are the pre-dispatch contents. The headless substrate records a dispatch and does not execute the shader, so computed values need a real device (see "Select a real backend").
1. Declare what your host lends the script. Handles are opaque pointers. Mark a type the sgpu mirror already declares as external, so this mirror references it instead of a second declaration of it:
/// @subscript-external
pub type SGPUTextureView = *mut c_void;
/// Opaque engine handle.
pub type EngineHandle = *mut EngineImpl;
/// Returns the current engine-owned offscreen render-target view.
///
/// # Safety
///
/// `engine` must be null or point to a live allocation from an engine create function.
#[no_mangle]
pub unsafe extern "C" fn engineAcquireFrameView(engine: EngineHandle) -> SGPUTextureView {
…
}Mark the lifecycle pair /// @subscript-host-only. The marker must be
the whole line, because a reason appended to it turns the marker off.
2. Regenerate the header and the mirror. One command
(cargo run -p subscript-gpu-codegen --offline) turns the Rust
declarations above into the C header:
/* @subscript-external SGPUDevice */
/* @subscript-external SGPUInstance */
/* @subscript-external SGPUTextureView */
typedef struct EngineImpl *EngineHandle;
uint32_t engineReady(EngineHandle engine);
EngineHandle engineCurrent(void);
SGPUInstance engineAcquireInstance(EngineHandle engine);
SGPUDevice engineAcquireDevice(EngineHandle engine);
SGPUTextureView engineAcquireFrameView(EngineHandle engine);
void engineProcessEvents(EngineHandle engine);
uint32_t engineFrameWidth(EngineHandle _engine);
uint32_t engineFrameHeight(EngineHandle _engine);
uint32_t engineReadbackFrame(EngineHandle engine);
uint32_t engineFrameSample(EngineHandle engine, uint32_t x, uint32_t y);
int32_t engineValue(EngineHandle engine);and that header into the ambient declarations a script compiles against:
declare function engineReady(engine: EngineHandle): u32;
declare function engineCurrent(): EngineHandle;
declare function engineAcquireInstance(engine: EngineHandle): SGPUInstance;
declare function engineAcquireDevice(engine: EngineHandle): SGPUDevice;
declare function engineAcquireFrameView(engine: EngineHandle): SGPUTextureView;
declare function engineProcessEvents(engine: EngineHandle): void;
declare function engineFrameWidth(_engine: EngineHandle): u32;
declare function engineFrameHeight(_engine: EngineHandle): u32;
declare function engineReadbackFrame(engine: EngineHandle): u32;
declare function engineFrameSample(engine: EngineHandle, x: u32, y: u32): u32;
declare function engineValue(engine: EngineHandle): i32;Neither list holds engineCreate or engineRelease. Host-only
functions leave both artifacts, so a script cannot name the lifecycle
of an object the host owns. That is a stronger rule than a review
comment: the script fails to compile.
3. Let the script borrow what you own. The script wraps your handles in API-layer types and never creates the frame target:
const device: GPUDevice = borrowGPUDevice(
engineAcquireInstance(frameEngine),
engineAcquireDevice(frameEngine),
);
const queue: GPUQueue = device.queue();
const frameView: GPUTextureView = new GPUTextureView(
engineAcquireFrameView(frameEngine),
);borrowGPUDevice is the borrow entry point and requestDevice() is
the owning one. Both give a GPUDevice. A borrowed wrapper releases
only what it acquired itself, so the host-owned device survives the
script's release:
command.release();
pass.release();
encoder.release();
pipeline.release();
shader.release();
// The wrapper borrowed the engine's device: release drops wrapper-owned
// queue state but never releases the host-owned device handle.
queue.release();
device.release();
print("resources:released");4. Drive frames. Create what you own before the session. Pump it, step the script's async, then call the entry. Drain the pending work before the next frame:
for frame in 0..2 {
process_engine_events(engine, &mut actions);
step_async(&mut session, &mut actions)?;
actions.push(HostAction::ScriptEntry);
session
.call_main()
.map_err(|error| format!("call triangle frame {frame} entry: {error}"))?;
while session.async_pending() != 0 {
process_engine_events(engine, &mut actions);
step_async(&mut session, &mut actions)?;
}
}Each frame prints:
frame:target=64x64
render:encoded-clear=0,0,0.25,1:draw=3
completion:submitted=true
readback:ready=true
samples:device-only
resources:released
The ship tier compiles and links the program instead of hosting it,
so main belongs to the emitted program. Host code runs through two
named hooks:
run_c_aot_with_native_libraries_and_host_hooks(
&files(),
&libraries,
Some(ENGINE_PRE_ENTRY_HOOK),
Some(ENGINE_POST_RUN_HOOK),
)The pre-entry hook runs after subscript_init and before the
script's entry, and it creates what the host owns. The post-run hook
runs after the async pump and releases that. Post-run runs after a
trap as well, so what the host creates is always released.
The suite runs both examples under the development JIT and the shipped C build, and compares both against one committed golden.
- One Context belongs to one thread. Calls on a session, and the entries it runs, come from one thread at a time.
- A host pumps what it owns. A host with no GPU objects pumps nothing. A host that owns an instance pumps that instance.
- Async completes only if the host steps it. A host that never steps leaves the script parked forever, by design.
- Release order lives in the examples, not in prose. Both scripts release in order, in the script text, because readers copy examples.
- A headless run proves argument conversion, lifetimes, and
completion delivery. It proves nothing about pixels, which is why
the triangle prints
samples:device-only.
SUBSCRIPT_GPU_BACKEND takes default, metal, vulkan or gles.
With the variable unset the run uses the headless substrate. On a
real device the triangle's sample line becomes
samples:covered=true:not-covered=true. An unsatisfiable request
writes a diagnostic and returns a null instance. It never falls back
to the headless substrate, because a device run that quietly became a
headless run would prove nothing.
- How to obtain a backend library. This repository neither vendors an implementation nor documents a build of one.
- A host in your own crate. The generator reads
engine/src/lib.rsin this workspace and writes to fixed paths in it. A downstream crate must hand-maintain the dev-JIT symbol table and the facade ABI block, which no gate here covers. - Presentation. The frame target is an offscreen texture. A
required gate must run with no window, so the gated example opens
none. The reasons are in
specs/blocks/host-embedding.md(H3a).
docs/tutorial-c-cpp.md— the same two examples under a C or C++ host, through the ship tier.specs/blocks/host-embedding.md— the contract these examples follow.specs/blocks/api.md— how the API layer maps the WebGPU IDL, and where it deviates.