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
75 changes: 72 additions & 3 deletions examples/camera-virtual-camera/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,9 +71,78 @@ in the frame counts: over one 1080p run the passthrough camera wrote 1325 frames
while the inverted one wrote 523 — and both sinks' dropped-frame counters read
zero, so neither was outrun. The effect is simply slower, because a 1920×1080
frame goes out to the host and back for it.
An effect that stays on the GPU — a compute kernel, or a graphics pass as
`examples/camera-python-effects` writes them — does not pay that, and the two
cameras keep pace.
An effect that stays on the GPU does not pay that — `ShaderEffect`, below, is
one.

## One host, many looks

`processors/shader_effect.py` is an effect written once. It takes a fragment
shader as config, and the shader *is* the look — the engine compiles it at
`setup()` and draws it over every frame as one fullscreen pass, so the pixels
never leave the GPU. Three looks ship in `processors/shaders/` to start from:
`grayscale.frag`, `vignette.frag` and `pixelate.frag`.

```python
from processors.shader_effect import SHIPPED_SHADERS_DIRECTORY, ShaderEffect

vignette = rt.add(
ShaderEffect,
config={"fragment_glsl": (SHIPPED_SHADERS_DIRECTORY / "vignette.frag").read_text()},
)
```

A new look is a paragraph of GLSL, not a processor. The shader samples the
frame as a `sampler2D` named `upstream_frame` (or whatever
`sampled_input_binding_name` in the config says), may read `screen_uv` at
location 0 — 0..1 across the frame from the top left — and writes one colour:

```glsl
#version 450
layout(location = 0) in vec2 screen_uv;
layout(location = 0) out vec4 painted_colour;
layout(set = 0, binding = 0) uniform sampler2D upstream_frame;

void main() {
vec4 source = texture(upstream_frame, screen_uv);
painted_colour = vec4(source.bgr, source.a);
}
```

A shader that does not compile never gets as far as a frame: the effect is
refused at `setup()` with the compiler's own diagnostic in `streamlib logs`,
and every other processor in the graph keeps running.

The ports are the inverting effect's, so a look takes its place without
touching anything else. **Into the running app, over MCP**, that is one remove
and one add — `ShaderEffect` is importable as
`processors.shader_effect:ShaderEffect` from this directory, which is what
`add_processor` needs:

1. `remove_processor` with the `InvertingEffect`'s id from `graph`;
2. `add_processor` with `{"type": "processors.shader_effect:ShaderEffect",
"config": {"fragment_glsl": "…"}}`;
3. `connect` the camera's `video` to its `video_from_upstream`, and its
`video_to_downstream` to the inverted sink's `video`.

Swapping one look for another is the same remove and add again. The camera,
both sinks and every other processor keep running through it — only the second
camera goes without new frames for the moment the splice takes, under a second
on a 640×480 feed.

The effect keeps its source's rate: 30 fps in that 640×480 showcase run, and
30 fps from a synthetic 1920×1080 source into a counting sink.

Each frame is copied device-to-device into a texture the effect owns before the
pass samples it: a camera publishes buffer-backed frames, and a draw binds
texture-backed ones. `cupy` does nothing in the module but that copy.

The tests in `tests/` run each shipped look over a known pattern and check a
pixel against the same maths done on the CPU, and check that a broken shader is
refused. They boot an engine and build kernels, so they need an NVIDIA GPU:

```bash
uv run pytest
```

## Run it

Expand Down
151 changes: 151 additions & 0 deletions examples/camera-virtual-camera/processors/shader_effect.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
# Copyright (c) 2025 Jonathan Fontanez
# SPDX-License-Identifier: BUSL-1.1
"""One effect host, any number of looks: a fragment shader taken from config.

Importable as `processors.shader_effect:ShaderEffect`, which is the name the
engine spawns this processor's child interpreter with — and the name an agent
passes to `add_processor` to put a look into the running graph.

The host is written once. A look is the GLSL a `config` carries: the engine
compiles it at `setup()`, draws it over every frame as one fullscreen pass,
and the pixels never leave the GPU. `shaders/` holds three to start from.

What a fragment shader gets: the frame as a `sampler2D` under the name
`sampled_input_binding_name` says (`upstream_frame` unless the config names
another), `screen_uv` at location 0 running 0..1 from the top left, and one
colour output. It declares no other binding and no push constants.
"""

from dataclasses import dataclass
from pathlib import Path
from typing import Any

import cupy
from streamlib import (
ProcessorOutputTextureRing,
RuntimeContextFullAccess,
RuntimeContextLimitedAccess,
VideoFrame,
input, # noqa: A004 — streamlib's port decorator
output,
processor,
)

SHIPPED_SHADERS_DIRECTORY = Path(__file__).parent / "shaders"

FULLSCREEN_TRIANGLE_VERTEX_GLSL = (
SHIPPED_SHADERS_DIRECTORY / "fullscreen_triangle.vert"
).read_text(encoding="utf-8")

# One format end to end: the camera publishes RGBA8 and a `VirtualCameraSink`
# samples RGBA8 on its way to the device's buffers.
LANDING_AND_RENDERED_FRAME_TEXTURE_FORMAT = "rgba8_unorm"

# The texture each incoming frame lands in and the shader samples.
SAMPLED_LANDING_TEXTURE_USAGE = ["texture_binding"]

# The texture the pass renders into and the next processor samples.
RENDERED_OUTPUT_TEXTURE_USAGE = ["render_attachment", "texture_binding"]


@dataclass
class ShaderEffectConfig:
"""The look: fragment GLSL, and the name it gives the frame it samples."""

fragment_glsl: str
sampled_input_binding_name: str = "upstream_frame"


class VideoFrameWithTheBagItArrivedIn:
"""A typed read's target that keeps the bag beside the frame cast from it.

The frame is constructed while the read is offering its claim, so it holds
the camera's pixels still for the landing copy; the bag is what gets
forwarded, because every key on it still describes the picture.
"""

def __init__(self, **bag: Any) -> None:
self.bag = bag
self.video_frame = VideoFrame(**bag)


@processor(description="Draws the fragment shader its config carries over every frame")
class ShaderEffect:
"""Frame in, the same frame through a fragment shader out."""

@input(delivery_profile="newest")
def video_from_upstream(self) -> None: ...

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ignoring. The ... body is the port-declaration idiom: @input / @output read the method's signature and never call its body. Every processor in the wheel's tests and the examples declares ports this way.


@output()
def video_to_downstream(self) -> None: ...

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ignoring. The ... body is the port-declaration idiom: @input / @output read the method's signature and never call its body. Every processor in the wheel's tests and the examples declares ports this way.


def __init__(self, config: ShaderEffectConfig) -> None:
self.fragment_glsl = config.fragment_glsl
self.sampled_input_binding_name = config.sampled_input_binding_name

def setup(self, ctx: RuntimeContextFullAccess) -> None:
try:
self.graphics_kernel = ctx.gpu_full_access.create_graphics_kernel(
color_attachment_formats=[LANDING_AND_RENDERED_FRAME_TEXTURE_FORMAT],
vertex_source=FULLSCREEN_TRIANGLE_VERTEX_GLSL,
fragment_source=self.fragment_glsl,
bindings={
self.sampled_input_binding_name: ("sampled_texture", ["fragment"])
},
label="ShaderEffect",
)
except RuntimeError as refusal:
raise RuntimeError(
f"ShaderEffect could not build its pass from `fragment_glsl` sampling "
f"`{self.sampled_input_binding_name}` "
f"(`sampled_input_binding_name`): {refusal}"
) from refusal
# Depth 1: the draw returns with the GPU work retired, and nothing
# outside this processor ever names a landing texture.
self.landing_texture_ring = ProcessorOutputTextureRing(
LANDING_AND_RENDERED_FRAME_TEXTURE_FORMAT,
SAMPLED_LANDING_TEXTURE_USAGE,
depth=1,
)
self.rendered_output_texture_ring = ProcessorOutputTextureRing(
LANDING_AND_RENDERED_FRAME_TEXTURE_FORMAT, RENDERED_OUTPUT_TEXTURE_USAGE
)

def process(self, ctx: RuntimeContextLimitedAccess) -> None:
arrival = ctx.inputs.read(
"video_from_upstream", into=VideoFrameWithTheBagItArrivedIn
)
if arrival is None:
return
frame = arrival.video_frame

# A camera publishes buffer-backed frames and a draw binds
# texture-backed surfaces only, so each frame is copied device-to-device
# into a texture this processor owns.
landing_texture = self.landing_texture_ring.next_texture_for_this_frame(
ctx.gpu_limited_access, frame.width, frame.height
)
with landing_texture.as_device_tensor() as writable_landing_texture:
cupy.from_dlpack(writable_landing_texture)[...] = cupy.from_dlpack(frame)

rendered_output_texture = (
self.rendered_output_texture_ring.next_texture_for_this_frame(
ctx.gpu_limited_access, frame.width, frame.height
)
)
self.graphics_kernel.draw(
bindings={self.sampled_input_binding_name: landing_texture},
color_targets=[rendered_output_texture],
extent=(frame.width, frame.height),
vertex_count=3,
)

# The upstream bag forwarded whole, with only the surface swapped: the
# capture stamp and the colour metadata still describe this picture,
# and a `VirtualCameraSink` sets the device's colorimetry from them.
rendered_bag = dict(arrival.bag)
rendered_bag["surface_id"] = rendered_output_texture.surface_id
# A per-frame layout override describes the surface it was published
# with, and this is a different surface.
rendered_bag.pop("texture_layout", None)
ctx.outputs.write("video_to_downstream", rendered_bag)
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// Copyright (c) 2025 Jonathan Fontanez
// SPDX-License-Identifier: BUSL-1.1

// The vertex stage every look shares. No vertex buffer is reachable from a
// Python processor, so three vertices are fabricated from `gl_VertexIndex` and
// cover the whole viewport:
// vertex 0: pos(-1,-1), uv(0,0) vertex 1: pos(3,-1), uv(2,0)
// vertex 2: pos(-1,3), uv(0,2)
// `screen_uv` reaches a fragment as 0..1 across the frame, (0,0) at the top
// left — the same corner the frame's first texel is in.

#version 450

layout(location = 0) out vec2 screen_uv;

void main() {
screen_uv = vec2((gl_VertexIndex << 1) & 2, gl_VertexIndex & 2);
gl_Position = vec4(screen_uv * 2.0 - 1.0, 0.0, 1.0);
}
18 changes: 18 additions & 0 deletions examples/camera-virtual-camera/processors/shaders/grayscale.frag
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// Copyright (c) 2025 Jonathan Fontanez
// SPDX-License-Identifier: BUSL-1.1

// Black and white: every pixel becomes its own brightness.

#version 450

layout(location = 0) in vec2 screen_uv;
layout(location = 0) out vec4 painted_colour;

layout(set = 0, binding = 0) uniform sampler2D upstream_frame;

void main() {
vec4 source = texture(upstream_frame, screen_uv);
// BT.709 luma, the weights the HD standard published for it.
float luma = dot(source.rgb, vec3(0.2126, 0.7152, 0.0722));
painted_colour = vec4(vec3(luma), source.a);
}
25 changes: 25 additions & 0 deletions examples/camera-virtual-camera/processors/shaders/pixelate.frag
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// Copyright (c) 2025 Jonathan Fontanez
// SPDX-License-Identifier: BUSL-1.1

// Big square pixels: the picture is diced into cells and each cell takes the
// colour at its centre.

#version 450

layout(location = 0) out vec4 painted_colour;

layout(set = 0, binding = 0) uniform sampler2D upstream_frame;

const int CELL_SIZE_IN_PIXELS = 16;

void main() {
ivec2 fragment_pixel_coordinate = ivec2(gl_FragCoord.xy);
ivec2 cell_origin = (fragment_pixel_coordinate / CELL_SIZE_IN_PIXELS) * CELL_SIZE_IN_PIXELS;
// Clamped because a cell hanging off the right or bottom edge has its
// centre outside the frame.
ivec2 cell_centre = min(
cell_origin + CELL_SIZE_IN_PIXELS / 2,
textureSize(upstream_frame, 0) - 1
);
painted_colour = texelFetch(upstream_frame, cell_centre, 0);
}
25 changes: 25 additions & 0 deletions examples/camera-virtual-camera/processors/shaders/vignette.frag
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// Copyright (c) 2025 Jonathan Fontanez
// SPDX-License-Identifier: BUSL-1.1

// A lens vignette: the middle of the picture untouched, the corners fading
// towards black.

#version 450

layout(location = 0) in vec2 screen_uv;
layout(location = 0) out vec4 painted_colour;

layout(set = 0, binding = 0) uniform sampler2D upstream_frame;

// Distances from the centre, in the frame's own 0..1 coordinates: nothing
// darkens inside the first, everything past the second is black. A corner sits
// at about 0.707, so it keeps a little of its picture.
const float FADE_STARTS_AT = 0.35;
const float FADE_ENDS_AT = 0.8;

void main() {
vec4 source = texture(upstream_frame, screen_uv);
float distance_from_centre = distance(screen_uv, vec2(0.5));
float light_kept = 1.0 - smoothstep(FADE_STARTS_AT, FADE_ENDS_AT, distance_from_centre);
painted_colour = vec4(source.rgb * light_kept, source.a);
}
19 changes: 13 additions & 6 deletions examples/camera-virtual-camera/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,13 @@
name = "camera-virtual-camera"
version = "0.1.0"
requires-python = ">=3.12"
# Both doors ship from 0.18.59, but a sink only takes a Python processor's
# frames from 0.18.60 — which is this example's second camera. 0.19.1 is the
# floor the example can actually run on: below it, a refused surface-store
# lookup leaked a plane fd per frame (#2207) and a desktop run died at about
# 30 s against the shell's 1024 descriptor limit.
dependencies = ["streamlib>=0.19.1", "numpy>=2.1"]
# 0.22.1 is the release `ShaderEffect`'s tests pass against.
dependencies = [
"streamlib>=0.22.1",
"numpy>=2.1",
# `ShaderEffect`'s landing copy, device-to-device.
"cupy-cuda13x>=14.2",
]

# streamlib is served from its own simple index until the PyPI publication that
# follows the project rename; everything else resolves from PyPI as usual.
Expand All @@ -18,3 +19,9 @@ explicit = true

[tool.uv.sources]
streamlib = { index = "streamlib" }

[dependency-groups]
dev = ["pytest>=8"]

[tool.pytest.ini_options]
testpaths = ["tests"]
Loading
Loading