-
Notifications
You must be signed in to change notification settings - Fork 1
feat(examples): ShaderEffect runs a fragment shader from config over each frame #2234
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
059cfca
feat(examples): ShaderEffect draws the fragment shader its config car…
tato123 28fc26a
docs(examples): the virtual-camera README shows one ShaderEffect host…
tato123 5b44385
fix(examples): ShaderEffect review round — explicit names, fact-only …
tato123 e098111
docs(examples): say which run each ShaderEffect rate figure came from
tato123 1878a8a
fix(examples): suppress Ruff A004 on the line ShaderEffect imports st…
tato123 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
151 changes: 151 additions & 0 deletions
151
examples/camera-virtual-camera/processors/shader_effect.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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: ... | ||
|
|
||
| @output() | ||
| def video_to_downstream(self) -> None: ... | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ignoring. The |
||
|
|
||
| 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) | ||
19 changes: 19 additions & 0 deletions
19
examples/camera-virtual-camera/processors/shaders/fullscreen_triangle.vert
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
18
examples/camera-virtual-camera/processors/shaders/grayscale.frag
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
25
examples/camera-virtual-camera/processors/shaders/pixelate.frag
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
25
examples/camera-virtual-camera/processors/shaders/vignette.frag
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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/@outputread the method's signature and never call its body. Every processor in the wheel's tests and the examples declares ports this way.