Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions CHANGELOG.md

Large diffs are not rendered by default.

3 changes: 1 addition & 2 deletions docs/graphics/rhi/compute-shaders.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,7 @@ if (bundle.wasOk())
```cpp
GpuShaderSource source;
source.language = GpuShaderLanguage::msl; // or hlsl, wgsl, glsl
source.code = mslSource;
source.codeSize = mslLength;
source.code = gpuShaderSourceBytes (mslSource); // must outlive the compile call

auto result = GpuComputePipeline::compile (device, source, { 256, 1, 1 });
```
Expand Down
68 changes: 37 additions & 31 deletions docs/graphics/rhi/pipelines.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,22 +84,31 @@ Describes one pipeline stage's compiled source plus its mandatory metadata:
struct GpuShaderSource
{
GpuShaderLanguage language = GpuShaderLanguage::wgsl; // wgsl | glsl | msl | hlsl
const void* code = nullptr; // source/bytecode
uint32_t codeSize = 0;

const uint8_t* bindingMap = nullptr; // mandatory RSTB sidecar
uint32_t bindingMapSize = 0;

const uint8_t* glFixup = nullptr; // GL-only name→slot table
uint32_t glFixupSize = 0;

const char* entryPoint = nullptr; // null → "vs_main" / "fs_main"
Span<const uint8> code; // source/bytecode
Span<const uint8> bindingMap; // mandatory RSTB sidecar
Span<const uint8> glFixup; // GL-only name→slot table
String entryPoint; // empty → "vs_main" / "fs_main"
};
```

`GpuShaderLanguage` values: `wgsl` (WebGPU), `glsl` (GLES 3.0+, GL path only),
`msl` (Metal only), `hlsl` (Direct3D only).

The three blob fields **own their data** (`std::vector<uint8>`), so a descriptor
built from temporaries stays valid for as long as the descriptor does.
`gpuShaderSourceBytes()` builds one from source text:

```cpp
GpuShaderSource vs;
vs.language = GpuShaderLanguage::glsl;
vs.code = gpuShaderSourceBytes (vertexSource); // const char* or String
vs.bindingMap = bindingMapBlob; // std::vector<uint8_t>
```

`GpuShaderSource` is exposed to Python too, with `code` / `bindingMap` /
`glFixup` as bytes-in, bytes-out properties; see
[GPU rendering from Python](../../scripting/python-rhi.md).

### Binding maps

`GpuPipeline` (and the underlying GPU layer) require a pre-compiled **RSTB
Expand Down Expand Up @@ -156,16 +165,14 @@ post-process pipeline: no vertex buffers, no culling, a single alpha-blended
```cpp
struct GpuPipelineOptions
{
const GpuVertexBufferLayout* vertexBuffers = nullptr; // null for fullscreen
uint32_t vertexBufferCount = 0;
std::vector<GpuVertexBufferLayout> vertexBuffers; // empty for fullscreen

GpuPrimitiveTopology topology = GpuPrimitiveTopology::triangleList;
GpuIndexFormat indexFormat = GpuIndexFormat::none;
GpuCullMode cullMode = GpuCullMode::none;
GpuFaceWinding winding = GpuFaceWinding::counterClockwise;

GpuColorTarget colorTargets[4] = {}; // up to 4; count 0 → one default target
uint32_t colorTargetCount = 0;
std::vector<GpuColorTarget> colorTargets; // up to 4; empty → one default target

GpuDepthStencilState depthStencil; // enabled = false by default
GpuStencilFaceState stencilFront, stencilBack;
Expand All @@ -176,31 +183,30 @@ struct GpuPipelineOptions
};
```

`vertexBuffers` and `colorTargets` own their contents, and so does
`GpuVertexBufferLayout::attributes`. Building a descriptor from temporaries is
therefore safe - no `static constexpr` table has to be kept alive alongside it.

### Custom geometry example

For 3D or custom 2D geometry, describe the vertex layout, enable culling, and
(optionally) depth testing:

```cpp
const GpuVertexAttribute attribs[] = {
{ GpuVertexFormat::float3, 0, 0 }, // position @location(0)
{ GpuVertexFormat::float4, sizeof (float) * 3, 1 }, // color @location(1)
{ GpuVertexFormat::float3, sizeof (float) * 7, 2 }, // normal @location(2)
};
GpuPipelineOptions options;

const GpuVertexBufferLayout layout {
options.vertexBuffers.emplace_back (
sizeof (float) * 10, // stride
GpuVertexStepMode::vertex,
attribs,
(uint32_t) std::size (attribs)
};

GpuPipelineOptions options;
options.vertexBuffers = &layout;
options.vertexBufferCount = 1;
options.indexFormat = GpuIndexFormat::uint16;
options.cullMode = GpuCullMode::back;
options.winding = GpuFaceWinding::counterClockwise;
std::vector<GpuVertexAttribute> {
{ GpuVertexFormat::float3, 0, 0 }, // position @location(0)
{ GpuVertexFormat::float4, sizeof (float) * 3, 1 }, // color @location(1)
{ GpuVertexFormat::float3, sizeof (float) * 7, 2 }, // normal @location(2)
});

options.indexFormat = GpuIndexFormat::uint16;
options.cullMode = GpuCullMode::back;
options.winding = GpuFaceWinding::counterClockwise;
options.depthStencil.enabled = true;
```

Expand Down Expand Up @@ -230,7 +236,7 @@ The pipeline configuration draws on a family of small enums, all mirroring the
- **`GpuTextureFormat`** - the full format set, documented under
[Buffers & Textures](buffers-and-textures.md#gputexture).

When `colorTargetCount` is greater than one, the pass must bind a matching
When `colorTargets` holds more than one entry, the pass must bind a matching
attachment for each target with `GpuRenderPass::setColorAttachment()`, and each
`colorTargets[i].format` must equal the format of the texture bound there.
Likewise `depthStencil.enabled` requires the pass to bind a depth attachment of
Expand Down
3 changes: 1 addition & 2 deletions docs/graphics/rhi/spinning-cube.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,7 @@ The cube uses a vertex + fragment shader pair. With the transpiler enabled, GLSL

```cpp
GpuPipelineOptions options;
options.vertexBuffers = &cubeLayout; // position/color/normal
options.vertexBufferCount = 1;
options.vertexBuffers.push_back (cubeLayout); // position/color/normal
options.indexFormat = GpuIndexFormat::uint16;
options.cullMode = GpuCullMode::back;
options.winding = GpuFaceWinding::counterClockwise;
Expand Down
22 changes: 16 additions & 6 deletions docs/graphics/rhi/targets.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,9 +88,14 @@ lower-level `GpuDevice::createOffscreenTarget` / `beginOffscreen` /
`endOffscreen` API.

```cpp
static GpuCanvas::Ptr GpuCanvas::create (GpuDevice::Ptr ctx, int width, int height);
static GpuCanvas::Ptr GpuCanvas::create (GraphicsContext& ctx, int width, int height,
std::optional<Color> clearColor = Colors::transparentBlack);
```

Unlike `GpuTarget::create()`, which takes a `GpuDevice::Ptr`, the canvas takes the
`GraphicsContext`: its 2D drawing path constructs a `Graphics`, and every
`Graphics` constructor requires a graphics context.

### 2D drawing path

```cpp
Expand All @@ -106,10 +111,15 @@ if (canvas != nullptr)
}
```

`beginDraw()` opens (or reopens) a 2D frame and returns the `Graphics` to draw
into. On the first call it opens a fresh offscreen 2D GPU frame; subsequent calls
discard the previous frame's `Graphics` and reopen a new one on the same
already-allocated target, avoiding per-frame GPU resource reallocation.
`beginDraw (const GpuFrameDescriptor& frameDesc = {})` opens (or reopens) a 2D
frame and returns the `Graphics` to draw into. On the first call it opens a
fresh offscreen 2D GPU frame; subsequent calls discard the previous frame's
`Graphics` and reopen a new one on the same already-allocated target, avoiding
per-frame GPU resource reallocation. `frameDesc` gives control over
`msaaSampleCount`, `ditherMode`, `loadOp` and `clearColor`; its
`renderTargetWidth`/`renderTargetHeight` are ignored and auto-filled from the
canvas. The default `{}` reproduces the previous behaviour (clear to
transparent black, no msaa).

### Custom-pass path

Expand All @@ -127,7 +137,7 @@ pass.finish();
| `getTarget()` | The underlying `GpuTarget` backing this canvas. |
| `getWidth()` / `getHeight()` | Canvas dimensions in pixels. |
| `beginRenderPass (frame, options)` | Begins a render pass targeting the backing texture. |
| `beginDraw()` | Opens/reopens a 2D frame; returns the `Graphics` to draw into. |
| `beginDraw (frameDesc = {})` | Opens/reopens a 2D frame; returns the `Graphics` to draw into. |
| `commit()` | Finalizes an open 2D command. Usually unnecessary (auto-commits). |
| `asTexture()` | GPU-texture view; auto-commits an open 2D frame. |
| `asImage()` | `Image` with GPU texture + CPU pixels; auto-commits. |
Expand Down
9 changes: 9 additions & 0 deletions docs/scripting/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,12 @@ API-surface documentation for the Python bindings are still to come.

- **Bindings** - the pybind11-based bridge to YUP core and graphics types.
- **Embedding** - driving YUP from a Python host.
- [**GPU rendering from Python**](python-rhi.md) - what differs between the
`yup_rhi` C++ API and its Python bindings.

```{toctree}
:hidden:
:maxdepth: 2

python-rhi
```
204 changes: 204 additions & 0 deletions docs/scripting/python-rhi.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
# GPU rendering from Python

The `yup_rhi` layer is exposed to Python more or less one-to-one with the C++
API described under [RHI](../graphics/rhi/index.md), so that page remains the
reference for what each call means. This page covers only what is different in
Python.

Working end-to-end scripts live in `python/demos`: `gpu_triangle.py` (fullscreen
pass), `gpu_effects.py` (2D canvas feeding a post-process pass) and
`gpu_cube.py` (vertex + index buffers, an uploaded texture, a sampler and a
depth attachment).

## Getting a device

RHI factories take a `GpuDevice`, not the `GraphicsContext` a `Graphics` hands
you, so go through `getGpuDevice()`:

```python
def paint(self, g: yup.Graphics):
ctx = g.getGraphicsContext()
if ctx is None or not ctx.isGpuAvailable():
return

device = ctx.getGpuDevice()
```

Off-screen and headless work can create a device directly. It returns `None`
rather than raising when the backend is unavailable:

```python
device = yup.GpuDevice.create(yup.GpuPlatform.Headless, yup.GpuDevice.Options())
if device is None:
...
```

## Failures raise, they do not return

The C++ factories that return `ResultValue<T>` raise a `RuntimeError` carrying
the error message instead:

```python
try:
pipeline = yup.GpuPipeline.compileFromGlsl(device, vertGlsl, fragGlsl, options)
except RuntimeError as error:
print(f"compile failed: {error}")
```

Factories that return a null pointer in C++ - `GpuTarget.create`,
`GpuTexture.create`, `GpuSampler.create`, `GpuBuffer.create` - return `None`.

`GpuShaderSource` owns its blob fields, so it is fully exposed. `code`,
`bindingMap` and `glFixup` are bytes-in, bytes-out properties accepting any
object supporting the buffer protocol, and read back as `bytes`:

```python
source = yup.GpuShaderSource()
source.language = yup.GpuShaderLanguage.glsl
source.code = vertexGlslBytes
source.bindingMap = bindingMapBlob
pipeline = yup.GpuPipeline.compile(device, source, fragmentSource, options)
```

## Descriptors own their data, and convert by value

`GpuVertexBufferLayout.attributes`, `GpuPipelineOptions.vertexBuffers` and
`GpuPipelineOptions.colorTargets` are `std::vector` members. pybind11 converts
them **by value**, so assign a whole list; mutating the list you read back does
nothing:

```python
options = yup.GpuPipelineOptions()

options.vertexBuffers = [
yup.GpuVertexBufferLayout(
44, # stride in bytes
yup.GpuVertexStepMode.vertex,
[
yup.GpuVertexAttribute(yup.GpuVertexFormat.float3, 0, 0),
yup.GpuVertexAttribute(yup.GpuVertexFormat.float2, 36, 3),
],
),
]

target = yup.GpuColorTarget()
target.format = yup.GpuTextureFormat.rgba8unorm
options.colorTargets = [target] # NOT options.colorTargets.append(...)
```

## Passing bytes

Everything that takes raw memory - `GpuRenderPass.setUniformBuffer`,
`GpuComputePass.setUniformBuffer`, `GpuBuffer.create`, `GpuDevice.createBuffer`
/ `updateBuffer` / `readBuffer`, `GpuTexture.upload` - accepts any object
supporting the buffer protocol (`bytes`, `bytearray`, `memoryview`, a numpy
array) and is sized in bytes:

```python
uniforms = struct.pack("<4f", angleY, angleX, aspect, 0.0)
rp.setUniformBuffer(0, 0, uniforms)
```

`readBuffer` writes into a *writable* buffer, so pass a `bytearray` or a numpy
array rather than `bytes`. `GpuTarget.readPixels()` is the exception: it takes no
argument and returns `width * height * 4` RGBA bytes, or `None` when readback is
unavailable. Only a target created by the `width`/`height` overload can be read
back - one backed by a directly allocated texture always returns `None`.

`GpuTexture.upload` takes the pixels separately from the region that places
them, since `GpuTextureDataDesc` has no exposed data pointer:

```python
region = yup.GpuTextureDataDesc()
region.width = region.height = 64
texture.upload(pixels, region)
```

## Frames and passes are context managers

`GpuFrame`, `GpuRenderPass` and `GpuComputePass` are move-only RAII types in
C++, and support `with` in Python. Leaving the block submits the frame or
finishes the pass:

```python
with yup.GpuFrame.begin(device) as frame:
with target.beginRenderPass(frame, yup.GpuRenderOptions(True, yup.GpuColor.black())) as rp:
rp.setPipeline(pipeline)
rp.draw(3)
```

A pass borrows the frame it records into, so the bindings keep the frame (and
the target) alive for as long as the pass object exists.

## Compute

Compute mirrors the render path: compile a `GpuComputePipeline`, open a
`GpuComputePass`, bind storage buffers and uniforms, dispatch. `GpuDevice.isComputeAvailable()`
gates the whole thing.

```python
if not device.isComputeAvailable():
return

pipeline = yup.GpuComputePipeline.compileFromGlsl(
device, COMPUTE_GLSL, yup.GpuWorkgroupSize(64, 1, 1))

data = struct.pack(f"<{count}f", *values)
buffer = yup.GpuBuffer.create(device, yup.GpuBufferType.storage, data)

with yup.GpuComputePass.begin(device) as pass_:
pass_.setPipeline(pipeline)
pass_.setStorageBuffer(0, 0, buffer)
pass_.setUniformBuffer(0, 1, struct.pack("<I", count))
pass_.dispatch((count + 63) // 64)

result = bytearray(len(data))
device.readBuffer(buffer, result)
```

`readBuffer` returns `False` when no new data has arrived yet rather than on
error - on WebGPU the readback trails the GPU by a frame or two - so keep the
destination between calls and redraw its previous contents.

Both `compileFromGlsl` and the raw `compile()` overload (taking a
`GpuShaderSource` per stage) are exposed. `compileFromBundle()` still needs a
`ShaderBundle`, which has no Python binding yet.

## Controlling the offscreen frame with `GpuFrameDescriptor`

`GpuCanvas.beginDraw()` takes an optional `GpuFrameDescriptor`, giving control
over msaa/dither/loadOp/clearColor for the offscreen 2D frame it opens.
`renderTargetWidth`/`renderTargetHeight` are ignored - they are always
auto-filled from the canvas:

```python
frameDesc = yup.GpuFrameDescriptor()
frameDesc.msaaSampleCount = 4
frameDesc.ditherMode = yup.GpuDitherMode.none
frameDesc.loadOp = yup.GpuLoadOp.clear
frameDesc.clearColor = yup.GpuColor.black()

g = canvas.beginDraw(frameDesc)
```

Calling `beginDraw()` with no argument keeps the previous behaviour: clear to
transparent black, no msaa, interleaved-gradient-noise dithering.

## Bit flags

`GpuColorWriteMask` composes with `|` and `&`:

```python
target.writeMask = yup.GpuColorWriteMask.red | yup.GpuColorWriteMask.alpha
```

## Probing capabilities

Block-compressed formats and float render targets are not universally
available. Probe before creating:

```python
if device.isFormatRenderable(yup.GpuTextureFormat.rgba16float):
...
print(device.getMaximumSampleCount(), device.isAnisotropicFilteringAvailable())
```
Loading
Loading